mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-17 05:31:16 +08:00
* feat(compute): query gateway listener requirements Signed-off-by: Evan Lezar <elezar@nvidia.com> * feat(compute): add Podman listener requirements Signed-off-by: Evan Lezar <elezar@nvidia.com> * test(docker): use default gateway bind address Signed-off-by: Evan Lezar <elezar@nvidia.com> * fix(gateway): avoid wildcard primary listener Signed-off-by: Evan Lezar <elezar@nvidia.com> * fix(podman): validate callback listener discovery Signed-off-by: Evan Lezar <elezar@nvidia.com> * fix(server): support split dual-stack listeners Signed-off-by: Evan Lezar <elezar@nvidia.com> * fix(podman): support legacy rootless listener discovery Signed-off-by: Evan Lezar <elezar@nvidia.com> * test(e2e): accept loopback plaintext rejection Signed-off-by: Evan Lezar <elezar@nvidia.com> * docs(agent): add callback listener diagnostics Signed-off-by: Evan Lezar <elezar@nvidia.com> * fix(server): restrict compute callback listeners Signed-off-by: Evan Lezar <elezar@nvidia.com> * fix(podman): validate local callback port Signed-off-by: Evan Lezar <elezar@nvidia.com> * test(server): clarify callback listener contract Signed-off-by: Evan Lezar <elezar@nvidia.com> * fix(podman): require pasta for local callbacks Signed-off-by: Evan Lezar <elezar@nvidia.com> * docs(gateway): document RPM listener default Signed-off-by: Evan Lezar <elezar@nvidia.com> * refactor(server): keep listener provenance diagnostic-only Signed-off-by: Evan Lezar <elezar@nvidia.com> * fix(compute): preserve callback listener isolation Signed-off-by: Drew Newberry <anewberry@nvidia.com> * test(e2e): remove Podman callback relay Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(packaging): preserve Podman callback loopback Signed-off-by: Drew Newberry <anewberry@nvidia.com> * ci(e2e): run VM smoke on nested-virt runner Signed-off-by: Drew Newberry <anewberry@nvidia.com> * ci(e2e): gate VM smoke on usable KVM Signed-off-by: Drew Newberry <anewberry@nvidia.com> * ci(e2e): probe KVM through VM driver Signed-off-by: Drew Newberry <anewberry@nvidia.com> * ci(e2e): tolerate hosted KVM denial Signed-off-by: Drew Newberry <anewberry@nvidia.com> * test(server): close traced futures before assertions Signed-off-by: Drew Newberry <anewberry@nvidia.com> * revert: remove tracing test stabilization Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Evan Lezar <elezar@nvidia.com> Signed-off-by: Drew Newberry <anewberry@nvidia.com> Co-authored-by: Drew Newberry <anewberry@nvidia.com>
198 lines
7.2 KiB
Rust
198 lines
7.2 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||
// SPDX-License-Identifier: Apache-2.0
|
||
|
||
use crate::jwt::{JwtKeyMaterial, generate_jwt_key};
|
||
use miette::{IntoDiagnostic, Result, WrapErr};
|
||
use rcgen::{BasicConstraints, CertificateParams, DnType, Ia5String, IsCa, KeyPair, SanType};
|
||
use std::net::IpAddr;
|
||
|
||
/// All PEM-encoded materials produced by [`generate_pki`].
|
||
#[allow(clippy::struct_field_names)]
|
||
pub struct PkiBundle {
|
||
pub ca_cert_pem: String,
|
||
#[allow(dead_code)]
|
||
pub ca_key_pem: String,
|
||
pub server_cert_pem: String,
|
||
pub server_key_pem: String,
|
||
pub client_cert_pem: String,
|
||
pub client_key_pem: String,
|
||
/// PKCS#8 PEM Ed25519 private key for minting per-sandbox JWTs.
|
||
pub jwt_signing_key_pem: String,
|
||
/// SPKI PEM Ed25519 public key, paired with `jwt_signing_key_pem`.
|
||
pub jwt_public_key_pem: String,
|
||
/// Stable identifier embedded in the `kid` header of every minted JWT.
|
||
pub jwt_key_id: String,
|
||
}
|
||
|
||
/// Default SANs always included on the server certificate.
|
||
///
|
||
/// Covers the host aliases used by every supported runtime: Kubernetes service DNS,
|
||
/// `host.docker.internal` for Docker Desktop and rootless Docker on Linux,
|
||
/// and `host.containers.internal` for Podman containers reaching their host.
|
||
pub const DEFAULT_SERVER_SANS: &[&str] = &[
|
||
"openshell",
|
||
"openshell.openshell.svc",
|
||
"openshell.openshell.svc.cluster.local",
|
||
"localhost",
|
||
"openshell.localhost",
|
||
"*.openshell.localhost",
|
||
"host.docker.internal",
|
||
"host.containers.internal",
|
||
"127.0.0.1",
|
||
"::1",
|
||
];
|
||
|
||
/// Generate a complete PKI bundle: CA, server cert, and client cert.
|
||
///
|
||
/// `extra_sans` are additional Subject Alternative Names to add to the server
|
||
/// certificate (e.g. the remote host's IP or hostname for remote deployments).
|
||
///
|
||
/// Certificate validity uses the `rcgen` defaults (1975–4096), which effectively
|
||
/// never expire. This is appropriate for an internal dev-cluster PKI where certs
|
||
/// are ephemeral to the cluster's lifetime.
|
||
pub fn generate_pki(extra_sans: &[String]) -> Result<PkiBundle> {
|
||
// --- CA ---
|
||
let ca_key = KeyPair::generate()
|
||
.into_diagnostic()
|
||
.wrap_err("failed to generate CA key")?;
|
||
let mut ca_params = CertificateParams::new(Vec::<String>::new())
|
||
.into_diagnostic()
|
||
.wrap_err("failed to create CA params")?;
|
||
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||
ca_params
|
||
.distinguished_name
|
||
.push(DnType::OrganizationName, "openshell");
|
||
ca_params
|
||
.distinguished_name
|
||
.push(DnType::CommonName, "openshell-ca");
|
||
|
||
let ca_cert = ca_params
|
||
.self_signed(&ca_key)
|
||
.into_diagnostic()
|
||
.wrap_err("failed to self-sign CA certificate")?;
|
||
|
||
// --- Server cert ---
|
||
let server_key = KeyPair::generate()
|
||
.into_diagnostic()
|
||
.wrap_err("failed to generate server key")?;
|
||
let server_sans = build_server_sans(extra_sans);
|
||
let mut server_params = CertificateParams::new(Vec::<String>::new())
|
||
.into_diagnostic()
|
||
.wrap_err("failed to create server cert params")?;
|
||
server_params.subject_alt_names = server_sans;
|
||
server_params
|
||
.distinguished_name
|
||
.push(DnType::CommonName, "openshell-server");
|
||
|
||
let server_cert = server_params
|
||
.signed_by(&server_key, &ca_cert, &ca_key)
|
||
.into_diagnostic()
|
||
.wrap_err("failed to sign server certificate")?;
|
||
|
||
// --- Client cert (shared by CLI and sandbox pods) ---
|
||
let client_key = KeyPair::generate()
|
||
.into_diagnostic()
|
||
.wrap_err("failed to generate client key")?;
|
||
let mut client_params = CertificateParams::new(Vec::<String>::new())
|
||
.into_diagnostic()
|
||
.wrap_err("failed to create client cert params")?;
|
||
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)
|
||
.into_diagnostic()
|
||
.wrap_err("failed to sign client certificate")?;
|
||
|
||
// --- JWT signing key (Ed25519, used to mint per-sandbox identity tokens) ---
|
||
let JwtKeyMaterial {
|
||
signing_key_pem: jwt_signing_key_pem,
|
||
public_key_pem: jwt_public_key_pem,
|
||
kid: jwt_key_id,
|
||
} = generate_jwt_key().wrap_err("failed to generate JWT signing key")?;
|
||
|
||
Ok(PkiBundle {
|
||
ca_cert_pem: ca_cert.pem(),
|
||
ca_key_pem: ca_key.serialize_pem(),
|
||
server_cert_pem: server_cert.pem(),
|
||
server_key_pem: server_key.serialize_pem(),
|
||
client_cert_pem: client_cert.pem(),
|
||
client_key_pem: client_key.serialize_pem(),
|
||
jwt_signing_key_pem,
|
||
jwt_public_key_pem,
|
||
jwt_key_id,
|
||
})
|
||
}
|
||
|
||
/// Build the SAN list for the server certificate from defaults + extras.
|
||
fn build_server_sans(extra_sans: &[String]) -> Vec<SanType> {
|
||
let mut sans = Vec::new();
|
||
|
||
for s in DEFAULT_SERVER_SANS {
|
||
add_san(&mut sans, s);
|
||
}
|
||
for s in extra_sans {
|
||
add_san(&mut sans, s);
|
||
}
|
||
|
||
sans
|
||
}
|
||
|
||
/// Add a SAN, automatically choosing `IpAddress` or `DnsName` based on the value.
|
||
fn add_san(sans: &mut Vec<SanType>, value: &str) {
|
||
if let Ok(ip) = value.parse::<IpAddr>() {
|
||
sans.push(SanType::IpAddress(ip));
|
||
} else if let Ok(dns) = Ia5String::try_from(value) {
|
||
sans.push(SanType::DnsName(dns));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn generate_pki_produces_valid_pem() {
|
||
let bundle = generate_pki(&["10.0.0.1".to_string(), "myhost.example.com".to_string()])
|
||
.expect("generate_pki failed");
|
||
|
||
// All PEM strings should be non-empty and contain PEM markers
|
||
assert!(bundle.ca_cert_pem.contains("BEGIN CERTIFICATE"));
|
||
assert!(bundle.ca_key_pem.contains("BEGIN PRIVATE KEY"));
|
||
assert!(bundle.server_cert_pem.contains("BEGIN CERTIFICATE"));
|
||
assert!(bundle.server_key_pem.contains("BEGIN PRIVATE KEY"));
|
||
assert!(bundle.client_cert_pem.contains("BEGIN CERTIFICATE"));
|
||
assert!(bundle.client_key_pem.contains("BEGIN PRIVATE KEY"));
|
||
assert!(bundle.jwt_signing_key_pem.contains("BEGIN PRIVATE KEY"));
|
||
assert!(bundle.jwt_public_key_pem.contains("BEGIN PUBLIC KEY"));
|
||
assert_eq!(bundle.jwt_key_id.len(), 32, "kid is 16 bytes hex-encoded");
|
||
}
|
||
|
||
#[test]
|
||
fn generate_pki_no_extra_sans() {
|
||
let bundle = generate_pki(&[]).expect("generate_pki failed");
|
||
assert!(bundle.server_cert_pem.contains("BEGIN CERTIFICATE"));
|
||
}
|
||
|
||
#[test]
|
||
fn build_server_sans_includes_defaults_and_extras() {
|
||
let extras = vec!["192.168.1.100".to_string(), "remote.host".to_string()];
|
||
let sans = build_server_sans(&extras);
|
||
|
||
// Should have all default SANs + 2 extras
|
||
assert_eq!(sans.len(), DEFAULT_SERVER_SANS.len() + 2);
|
||
}
|
||
|
||
#[test]
|
||
fn default_server_sans_include_local_container_hostnames() {
|
||
assert!(DEFAULT_SERVER_SANS.contains(&"host.docker.internal"));
|
||
assert!(DEFAULT_SERVER_SANS.contains(&"host.containers.internal"));
|
||
assert!(DEFAULT_SERVER_SANS.contains(&"127.0.0.1"));
|
||
assert!(DEFAULT_SERVER_SANS.contains(&"::1"));
|
||
}
|
||
}
|