use std::borrow::Cow;
use std::collections::HashMap;
use std::fs::{self, DirEntry, File, ReadDir};
use std::io::Write;
use std::path::{Path, PathBuf};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use brane_cfg::certs::load_all;
use brane_shr::formatters::PrettyListFormatter;
use console::{Alignment, pad_str, style};
use dialoguer::Confirm;
use enum_debug::EnumDebug;
use prettytable::Table;
use prettytable::format::FormatBuilder;
use rustls::{Certificate, PrivateKey};
use x509_parser::certificate::X509Certificate;
use x509_parser::extensions::{ParsedExtension, X509Extension};
use x509_parser::oid_registry::OID_X509_EXT_KEY_USAGE;
use x509_parser::prelude::FromDer as _;
use x509_parser::x509::X509Name;
pub use crate::errors::CertsError as Error;
use crate::instance::InstanceInfo;
use crate::utils::{ensure_instances_dir, get_instance_dir};
fn resolve_instance(name: Option<String>) -> Result<(String, PathBuf), Error> {
if let Some(name) = name {
match get_instance_dir(&name) {
Ok(path) => match path.exists() {
true => Ok((name, path)),
false => Err(Error::UnknownInstance { name }),
},
Err(err) => Err(Error::InstanceDirError { err }),
}
} else {
match InstanceInfo::get_active_name() {
Ok(name) => match InstanceInfo::get_instance_path(&name) {
Ok(path) => Ok((name, path)),
Err(err) => Err(Error::InstancePathError { name, err }),
},
Err(err) => Err(Error::ActiveInstanceReadError { err }),
}
}
}
fn analyse_cert(cert: &Certificate, path: impl Into<PathBuf>, i: usize) -> Result<(CertificateKind, Option<String>), Error> {
let cert: X509Certificate = match X509Certificate::from_der(&cert.0) {
Ok((_, cert)) => cert,
Err(err) => {
return Err(Error::CertParseError { path: path.into(), i, err });
},
};
let exts: HashMap<_, _> = match cert.extensions_map() {
Ok(exts) => exts,
Err(err) => {
return Err(Error::CertExtensionsError { path: path.into(), i, err });
},
};
let usage: &X509Extension = match exts.get(&OID_X509_EXT_KEY_USAGE) {
Some(usage) => usage,
None => {
return Err(Error::CertNoKeyUsageError { path: path.into(), i });
},
};
let kind: CertificateKind = match usage.parsed_extension() {
ParsedExtension::KeyUsage(ext) => {
let ds: bool = ext.digital_signature();
let cs: bool = ext.crl_sign();
if ds && !cs {
CertificateKind::Client
} else if !ds && cs {
CertificateKind::Ca
} else if ds && cs {
CertificateKind::Both
} else {
return Err(Error::CertNoUsageError { path: path.into(), i });
}
},
_ => {
unreachable!();
},
};
let mut domain_name: Option<String> = None;
let issuer: &X509Name = cert.issuer();
for name in issuer.iter_common_name() {
let name: &str = match name.as_str() {
Ok(name) => name,
Err(err) => {
return Err(Error::CertIssuerCaError { path: path.into(), i, err });
},
};
if name.len() >= 7 && &name[..7] == "CA for " {
domain_name = Some(name[7..].into());
}
}
Ok((kind, domain_name))
}
#[derive(Clone, Copy, Debug, EnumDebug, Eq, Hash, PartialEq)]
enum CertificateKind {
Both,
Ca,
Client,
}
pub fn get_active_certs_dir(domain: impl AsRef<Path>) -> Result<PathBuf, Error> {
let active_path: PathBuf = match InstanceInfo::get_active_name() {
Ok(name) => match InstanceInfo::get_instance_path(&name) {
Ok(path) => path,
Err(err) => {
return Err(Error::InstancePathError { name, err });
},
},
Err(err) => {
return Err(Error::ActiveInstanceReadError { err });
},
};
Ok(active_path.join("certs").join(domain))
}
pub fn add(instance_name: Option<String>, paths: Vec<PathBuf>, mut domain_name: Option<String>, force: bool) -> Result<(), Error> {
info!("Adding certificate file(s) '{:?}'...", paths);
let (instance_name, instance_path): (String, PathBuf) = resolve_instance(instance_name)?;
debug!("Adding for instance: '{}' ({})", instance_name, instance_path.display());
let mut ca_cert: Option<Certificate> = None;
let mut client_cert: Option<Certificate> = None;
let mut client_key: Option<PrivateKey> = None;
for path in &paths {
debug!("Reading certificate '{}'...", path.display());
let (certs, keys): (Vec<Certificate>, Vec<PrivateKey>) = match load_all(path) {
Ok(res) => res,
Err(err) => {
return Err(Error::PemLoadError { path: path.clone(), err });
},
};
if certs.is_empty() && keys.is_empty() {
warn!("Empty file '{}' (at least, no valid certificates or keys found)", path.display());
continue;
}
for (i, key) in keys.into_iter().enumerate() {
if client_key.is_some() {
warn!("Multiple private keys specified, ignoring key {} in file '{}'", i, path.display());
continue;
}
client_key = Some(key);
}
for (i, c) in certs.into_iter().enumerate() {
let (kind, cert_domain): (CertificateKind, Option<String>) = match analyse_cert(&c, path, i) {
Ok(res) => res,
Err(err) => {
warn!("{} (skipping)", err);
continue;
},
};
debug!("Certificate {} in '{}' is a {} certificate for {:?}", i, path.display(), kind.variant(), cert_domain);
if let Some(domain_name) = &domain_name {
if let Some(cert_domain) = &cert_domain {
if cert_domain != domain_name {
warn!(
"Certificate {} in '{}' appears to be issued for domain '{}', but you are adding it for domain '{}'",
i,
path.display(),
cert_domain,
domain_name
);
}
} else {
warn!("Certificate {} in '{}' does not have a domain name specified", i, path.display());
}
} else {
domain_name = cert_domain;
}
match kind {
CertificateKind::Both => {
match ca_cert.is_some() {
true => {
warn!("Multiple CA certificates specified, ignoring certificate {} in file '{}'", i, path.display());
continue;
},
false => {
ca_cert = Some(c.clone());
},
}
match client_cert.is_some() {
true => {
warn!("Multiple client certificates specified, ignoring certificate {} in file '{}'", i, path.display());
continue;
},
false => {
client_cert = Some(c);
},
}
},
CertificateKind::Ca => match ca_cert.is_some() {
true => {
warn!("Multiple CA certificates specified, ignoring certificate {} in file '{}'", i, path.display());
continue;
},
false => {
ca_cert = Some(c);
},
},
CertificateKind::Client => match client_cert.is_some() {
true => {
warn!("Multiple client certificates specified, ignoring certificate {} in file '{}'", i, path.display());
continue;
},
false => {
client_cert = Some(c);
},
},
}
}
}
let ca_cert: Certificate = match ca_cert {
Some(cert) => cert,
None => {
return Err(Error::NoCaCert);
},
};
let client_cert: Certificate = match client_cert {
Some(cert) => cert,
None => {
return Err(Error::NoClientCert);
},
};
let client_key: PrivateKey = match client_key {
Some(key) => key,
None => {
return Err(Error::NoClientKey);
},
};
let domain_name: String = match domain_name {
Some(name) => name,
None => {
return Err(Error::NoDomainName);
},
};
let certs_path: PathBuf = instance_path.join("certs").join(&domain_name);
if certs_path.exists() {
if !certs_path.is_dir() {
return Err(Error::CertsDirNotADir { path: certs_path });
}
if !force {
debug!("Asking for confirmation...");
println!(
"A certificate for domain {} in instance {} already exists. Overwrite?",
style(&domain_name).cyan().bold(),
style(&instance_name).cyan().bold()
);
let consent: bool = match Confirm::new().interact() {
Ok(consent) => consent,
Err(err) => {
return Err(Error::ConfirmationError { err });
},
};
if !consent {
println!("Not overwriting, aborted.");
return Ok(());
}
if let Err(err) = fs::remove_dir_all(&certs_path) {
return Err(Error::CertsDirRemoveError { path: certs_path, err });
}
}
}
debug!("Creating directory '{}'...", certs_path.display());
if let Err(err) = fs::create_dir_all(&certs_path) {
return Err(Error::CertsDirCreateError { path: certs_path, err });
}
{
let ca_path: PathBuf = certs_path.join("ca.pem");
debug!("Writing CA certificates to '{}'...", ca_path.display());
let mut handle: File = match File::create(&ca_path) {
Ok(handle) => handle,
Err(err) => {
return Err(Error::FileOpenError { what: "ca", path: ca_path, err });
},
};
if let Err(err) = writeln!(handle, "-----BEGIN CERTIFICATE-----") {
return Err(Error::FileWriteError { what: "ca", path: ca_path, err });
}
for chunk in STANDARD.encode(ca_cert.0).as_bytes().chunks(64) {
if let Err(err) = handle.write(chunk) {
return Err(Error::FileWriteError { what: "ca", path: ca_path, err });
}
if let Err(err) = writeln!(handle) {
return Err(Error::FileWriteError { what: "ca", path: ca_path, err });
}
}
if let Err(err) = writeln!(handle, "-----END CERTIFICATE-----") {
return Err(Error::FileWriteError { what: "ca", path: ca_path, err });
}
}
{
let client_path: PathBuf = certs_path.join("client-id.pem");
debug!("Writing client certificates & keys to '{}'...", client_path.display());
let mut handle: File = match File::create(&client_path) {
Ok(handle) => handle,
Err(err) => {
return Err(Error::FileOpenError { what: "client ID", path: client_path, err });
},
};
if let Err(err) = writeln!(handle, "-----BEGIN CERTIFICATE-----") {
return Err(Error::FileWriteError { what: "client ID", path: client_path, err });
}
for chunk in STANDARD.encode(client_cert.0).as_bytes().chunks(64) {
if let Err(err) = handle.write(chunk) {
return Err(Error::FileWriteError { what: "client ID", path: client_path, err });
}
if let Err(err) = writeln!(handle) {
return Err(Error::FileWriteError { what: "client ID", path: client_path, err });
}
}
if let Err(err) = writeln!(handle, "-----END CERTIFICATE-----") {
return Err(Error::FileWriteError { what: "client ID", path: client_path, err });
}
if let Err(err) = writeln!(handle, "-----BEGIN RSA PRIVATE KEY-----") {
return Err(Error::FileWriteError { what: "client ID", path: client_path, err });
}
for chunk in STANDARD.encode(client_key.0).as_bytes().chunks(64) {
if let Err(err) = handle.write(chunk) {
return Err(Error::FileWriteError { what: "client ID", path: client_path, err });
}
if let Err(err) = writeln!(handle) {
return Err(Error::FileWriteError { what: "client ID", path: client_path, err });
}
}
if let Err(err) = writeln!(handle, "-----END RSA PRIVATE KEY-----") {
return Err(Error::FileWriteError { what: "client ID", path: client_path, err });
}
}
println!("Successfully added certificates for domain {} in instance {}", style(domain_name).cyan().bold(), style(instance_name).cyan().bold());
Ok(())
}
pub fn remove(domain_names: Vec<String>, instance_name: Option<String>, force: bool) -> Result<(), Error> {
info!("Removing certificate file(s) '{:?}'...", domain_names);
if domain_names.is_empty() {
println!("No domains given for which to remove certificates.");
return Ok(());
}
let (instance_name, instance_path): (String, PathBuf) = resolve_instance(instance_name)?;
debug!("Removing for instance: '{}' ({})", instance_name, instance_path.display());
if !force {
debug!("Asking for confirmation...");
println!(
"Are you sure you want to remove the certificates for domain{} {}?",
if domain_names.len() > 1 { "s" } else { "" },
PrettyListFormatter::new(domain_names.iter().map(|n| style(n).bold().cyan()), "and")
);
let consent: bool = match Confirm::new().interact() {
Ok(consent) => consent,
Err(err) => {
return Err(Error::ConfirmationError { err });
},
};
if !consent {
println!("Aborted.");
return Ok(());
}
}
for name in domain_names {
debug!("Removing certs for domain '{}' in instance '{}'...", name, instance_name);
let certs_dir: PathBuf = instance_path.join("certs").join(&name);
if certs_dir.exists() {
if let Err(err) = fs::remove_dir_all(&certs_dir) {
warn!("Failed to remove directory '{}': {} (skipping)", certs_dir.display(), err);
continue;
}
} else {
println!("Domain {} does not have any certificates (skipping)", style(name).yellow().bold());
continue;
}
println!("Removed certificates for domain {} in instance {}", style(name).cyan().bold(), style(&instance_name).cyan().bold());
}
Ok(())
}
pub fn list(instance_name: Option<String>, all: bool) -> Result<(), Error> {
info!("Listing certificates...");
let format = FormatBuilder::new().column_separator('\0').borders('\0').padding(1, 1).build();
let mut table = Table::new();
table.set_format(format);
table.add_row(row!["INSTANCE", "DOMAIN", "CA", "CLIENT"]);
let instances: Vec<(String, PathBuf)> = if all {
debug!("Finding instances...");
let instances_dir: PathBuf = match ensure_instances_dir(true) {
Ok(dir) => dir,
Err(err) => {
return Err(Error::InstancesDirError { err });
},
};
let entries: ReadDir = match fs::read_dir(&instances_dir) {
Ok(entries) => entries,
Err(err) => {
return Err(Error::DirReadError { what: "instances", path: instances_dir, err });
},
};
let mut instances: Vec<(String, PathBuf)> = Vec::with_capacity(entries.size_hint().1.unwrap_or(entries.size_hint().0));
for (i, entry) in entries.enumerate() {
let entry: DirEntry = match entry {
Ok(entries) => entries,
Err(err) => {
return Err(Error::DirEntryReadError { what: "instances", path: instances_dir, entry: i, err });
},
};
let entry_path: PathBuf = entry.path();
if !entry_path.is_dir() {
debug!("Skipping entry '{}' (not a directory)", entry_path.display());
continue;
}
if !entry_path.join("info.yml").is_file() {
debug!("Skipping entry '{}' (no nested info.yml file)", entry_path.display());
continue;
}
instances.push((entry.file_name().to_string_lossy().into(), entry_path));
}
instances
} else {
let (instance_name, instance_path): (String, PathBuf) = resolve_instance(instance_name)?;
vec![(instance_name, instance_path)]
};
debug!("Finding domains in instances {:?}...", instances.iter().map(|(n, p)| format!("'{}' ({})", n, p.display())).collect::<Vec<String>>());
for (name, path) in instances {
let certs_dir: PathBuf = path.join("certs");
if !certs_dir.exists() {
if let Err(err) = fs::create_dir_all(&certs_dir) {
return Err(Error::CertsDirCreateError { path: certs_dir, err });
}
}
let entries: ReadDir = match fs::read_dir(&certs_dir) {
Ok(entries) => entries,
Err(err) => {
return Err(Error::DirReadError { what: "certificates", path: certs_dir, err });
},
};
for (i, entry) in entries.enumerate() {
let entry: DirEntry = match entry {
Ok(entries) => entries,
Err(err) => {
return Err(Error::DirEntryReadError { what: "certificates", path: certs_dir, entry: i, err });
},
};
let entry_path: PathBuf = entry.path();
if !entry_path.is_dir() {
debug!("Skipping entry '{}' (not a directory)", entry_path.display());
continue;
}
let ca_path: PathBuf = entry_path.join("ca.pem");
if !ca_path.is_file() {
debug!("Skipping entry '{}' (no nested ca.pem file)", entry_path.display());
continue;
}
let client_path: PathBuf = entry_path.join("client-id.pem");
if !client_path.is_file() {
debug!("Skipping entry '{}' (no nested client-id.pem file)", entry_path.display());
continue;
}
let domain_name: String = entry.file_name().to_string_lossy().into();
let ca_path: Cow<str> = ca_path.to_string_lossy();
let client_path: Cow<str> = client_path.to_string_lossy();
let instance_name: Cow<str> = pad_str(&name, 20, Alignment::Left, Some(".."));
let domain_name: Cow<str> = pad_str(&domain_name, 20, Alignment::Left, Some(".."));
let ca_path: Cow<str> = pad_str(&ca_path, 30, Alignment::Left, Some(".."));
let client_path: Cow<str> = pad_str(&client_path, 30, Alignment::Left, Some(".."));
table.add_row(row![instance_name, domain_name, ca_path, client_path]);
}
}
table.printstd();
Ok(())
}