Files
Evan LezarandDrew Newberry d220d89468 feat(compute): negotiate gateway callback listeners (#2492)
* 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>
2026-07-31 16:41:06 +00:00

198 lines
7.2 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"));
}
}