220 lines
7.5 KiB
Rust
220 lines
7.5 KiB
Rust
use std::sync::Arc;
|
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
|
use rcgen::{
|
|
BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa,
|
|
Issuer, KeyPair, KeyUsagePurpose,
|
|
};
|
|
use rusqlite::Connection;
|
|
use rustls::server::WebPkiClientVerifier;
|
|
use rustls::RootCertStore;
|
|
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::info;
|
|
use uuid::Uuid;
|
|
|
|
use crate::db::{meta_set};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct JoinToken {
|
|
pub token: String,
|
|
pub expires_at: DateTime<Utc>,
|
|
pub used: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct JoinClusterRequest {
|
|
pub token: String,
|
|
pub node_id: Uuid,
|
|
#[serde(default)]
|
|
pub san_list: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct JoinClusterResponse {
|
|
pub ca_pem: String,
|
|
pub cert_pem: String,
|
|
pub key_pem: String,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Certificate Generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn generate_ca() -> Result<(String, String), String> {
|
|
let ca_key = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
|
|
.map_err(|e| format!("CA key error: {e}"))?;
|
|
|
|
let mut ca_params = CertificateParams::new(vec![])
|
|
.map_err(|e| format!("CA params error: {e}"))?;
|
|
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
|
ca_params.distinguished_name.push(DnType::CommonName, "Cluster Root CA");
|
|
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
|
|
|
let ca_cert = ca_params
|
|
.self_signed(&ca_key)
|
|
.map_err(|e| format!("CA self-sign error: {e}"))?;
|
|
|
|
Ok((ca_cert.pem(), ca_key.serialize_pem()))
|
|
}
|
|
|
|
pub fn issue_leaf_cert(
|
|
ca_cert_pem: &str,
|
|
ca_key_pem: &str,
|
|
common_name: &str,
|
|
san_list: Vec<String>,
|
|
) -> Result<(String, String), String> {
|
|
let ca_key = KeyPair::from_pem(ca_key_pem)
|
|
.map_err(|e| format!("Failed to parse CA private key: {e}"))?;
|
|
|
|
// In rcgen 0.14+, from_ca_cert_pem belongs to Issuer:
|
|
let issuer = Issuer::from_ca_cert_pem(ca_cert_pem, &ca_key)
|
|
.map_err(|e| format!("Failed to parse CA cert/create issuer: {e}"))?;
|
|
|
|
let leaf_key = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
|
|
.map_err(|e| format!("Leaf key error: {e}"))?;
|
|
|
|
let mut leaf_params = CertificateParams::new(san_list)
|
|
.map_err(|e| format!("Leaf params error: {e}"))?;
|
|
leaf_params.distinguished_name.push(DnType::CommonName, common_name);
|
|
leaf_params.use_authority_key_identifier_extension = true;
|
|
leaf_params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
|
|
leaf_params.extended_key_usages = vec![
|
|
ExtendedKeyUsagePurpose::ServerAuth,
|
|
ExtendedKeyUsagePurpose::ClientAuth,
|
|
];
|
|
|
|
let leaf_cert = leaf_params
|
|
.signed_by(&leaf_key, &issuer)
|
|
.map_err(|e| format!("Leaf sign error: {e}"))?;
|
|
|
|
Ok((leaf_cert.pem(), leaf_key.serialize_pem()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rustls Server & Reqwest Client Configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn parse_certs(pem: &str) -> Result<Vec<CertificateDer<'static>>, String> {
|
|
let mut reader = pem.as_bytes();
|
|
let certs: Result<Vec<_>, _> = rustls_pemfile::certs(&mut reader).collect();
|
|
certs.map_err(|e| format!("failed to parse certs: {e}"))
|
|
}
|
|
|
|
fn parse_key(pem: &str) -> Result<PrivateKeyDer<'static>, String> {
|
|
let mut reader = pem.as_bytes();
|
|
rustls_pemfile::private_key(&mut reader)
|
|
.map_err(|e| format!("failed to parse private key: {e}"))?
|
|
.ok_or_else(|| "no private key found in pem".to_string())
|
|
}
|
|
|
|
pub fn create_mtls_server_config(
|
|
ca_pem: &str,
|
|
server_cert_pem: &str,
|
|
server_key_pem: &str,
|
|
) -> Result<rustls::ServerConfig, String> {
|
|
let mut root_store = RootCertStore::empty();
|
|
for cert in parse_certs(ca_pem)? {
|
|
root_store
|
|
.add(cert)
|
|
.map_err(|e| format!("failed to add root cert: {e}"))?;
|
|
}
|
|
|
|
// allow_unauthenticated permits anonymous TLS connections (e.g., initial join requests)
|
|
// while enforcing valid CA signatures on any presented client certificates.
|
|
let client_verifier = WebPkiClientVerifier::builder(Arc::new(root_store))
|
|
.allow_unauthenticated()
|
|
.build()
|
|
.map_err(|e| format!("failed to build client verifier: {e}"))?;
|
|
|
|
let cert_chain = parse_certs(server_cert_pem)?;
|
|
let key = parse_key(server_key_pem)?;
|
|
|
|
let mut config = rustls::ServerConfig::builder()
|
|
.with_client_cert_verifier(client_verifier)
|
|
.with_single_cert(cert_chain, key)
|
|
.map_err(|e| format!("failed to build ServerConfig: {e}"))?;
|
|
|
|
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
|
Ok(config)
|
|
}
|
|
|
|
pub fn create_mtls_client(
|
|
ca_pem: &str,
|
|
client_cert_pem: &str,
|
|
client_key_pem: &str,
|
|
) -> Result<reqwest::Client, String> {
|
|
let root_cert = reqwest::Certificate::from_pem(ca_pem.as_bytes())
|
|
.map_err(|e| format!("failed to load CA pem for client: {e}"))?;
|
|
|
|
let identity_pem = format!("{client_cert_pem}\n{client_key_pem}");
|
|
let identity = reqwest::Identity::from_pem(identity_pem.as_bytes())
|
|
.map_err(|e| format!("failed to create client identity: {e}"))?;
|
|
|
|
reqwest::Client::builder()
|
|
.use_rustls_tls()
|
|
.add_root_certificate(root_cert)
|
|
.identity(identity)
|
|
.connect_timeout(std::time::Duration::from_secs(10))
|
|
.timeout(std::time::Duration::from_secs(60))
|
|
.build()
|
|
.map_err(|e| format!("failed to build reqwest client: {e}"))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dynamic Enrollment / Join Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn create_join_token(ttl_secs: u64) -> JoinToken {
|
|
JoinToken {
|
|
token: Uuid::new_v4().to_string().replace('-', ""),
|
|
expires_at: Utc::now() + ChronoDuration::seconds(ttl_secs as i64),
|
|
used: false,
|
|
}
|
|
}
|
|
|
|
pub async fn bootstrap_replica_join(
|
|
primary_url: &str,
|
|
token: &str,
|
|
node_id: Uuid,
|
|
db: &Connection,
|
|
) -> Result<(), String> {
|
|
let base = primary_url.trim_end_matches('/');
|
|
|
|
// 1. Initial bootstrap client without mTLS client cert
|
|
let bootstrap_client = reqwest::Client::builder()
|
|
.danger_accept_invalid_certs(true)
|
|
.build()
|
|
.map_err(|e| format!("bootstrap client build failed: {e}"))?;
|
|
|
|
// 2. Request enrollment
|
|
let join_req = JoinClusterRequest {
|
|
token: token.to_string(),
|
|
node_id,
|
|
san_list: vec!["localhost".into(), "127.0.0.1".into()],
|
|
};
|
|
|
|
let resp = bootstrap_client
|
|
.post(format!("{base}/api/cluster/join"))
|
|
.json(&join_req)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("join request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(format!("join rejected with HTTP {}", resp.status()));
|
|
}
|
|
|
|
let payload: JoinClusterResponse = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("failed to decode join response: {e}"))?;
|
|
|
|
// 3. Persist credentials locally
|
|
meta_set(db, "tls_ca_pem", &payload.ca_pem).map_err(|e| e.to_string())?;
|
|
meta_set(db, "tls_cert_pem", &payload.cert_pem).map_err(|e| e.to_string())?;
|
|
meta_set(db, "tls_key_pem", &payload.key_pem).map_err(|e| e.to_string())?;
|
|
|
|
info!("Replica successfully joined cluster and persisted mTLS certificates");
|
|
Ok(())
|
|
}
|