use std::path::Path;
use anyhow::{anyhow, bail, Context, Result};
pub const TLS_KEY: &str = "twin-tls.key";
pub const TLS_CSR: &str = "twin-tls.csr";
pub const TLS_CERT: &str = "twin-tls.crt";
pub const TLS_CA: &str = "twin-tls-ca.pem";
pub const TLS_PORT: &str = "twin-tls.port";
pub const REPLICATION_PORT_FILE: &str = "replication-listener.port";
pub const REPLICATION_HOST_FILE: &str = "replication-listener.host";
pub const TLS_HOST: &str = "twin-tls.host";
fn now_ms() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
}
fn require_twin(root: &Path) -> Result<()> {
if crate::twin::TwinConfig::load(root)?.is_none() {
bail!(
"this box is not a twin (no twin.json): a replication certificate is only minted and \
installed on a box that `gunnar control twin join` has made a twin"
);
}
Ok(())
}
fn write_private(root: &Path, name: &str, bytes: &[u8]) -> Result<()> {
crate::durable::write_atomic_unframed(root, name, bytes)?;
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(root.join(name), std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("restricting {name} to its owner"))?;
Ok(())
}
pub fn request(root: &Path) -> Result<String> {
require_twin(root)?;
let (key, csr) = (root.join(TLS_KEY), root.join(TLS_CSR));
if key.exists() && csr.exists() {
return std::fs::read_to_string(&csr).with_context(|| format!("reading {}", csr.display()));
}
let (key_pem, csr_pem) = gunnar_core::issuer::mint_client_request("gunnar-twin")?;
write_private(root, TLS_KEY, key_pem.as_bytes())?;
crate::durable::write_atomic_unframed(root, TLS_CSR, csr_pem.as_bytes())?;
Ok(csr_pem)
}
pub fn install(root: &Path, cert_pem: &str, ca_pem: &str, replication_port: u16) -> Result<gunnar_core::expiry::Expiry> {
install_at(root, cert_pem, ca_pem, replication_port, None)
}
pub fn install_at(root: &Path, cert_pem: &str, ca_pem: &str, replication_port: u16, host: Option<&str>) -> Result<gunnar_core::expiry::Expiry> {
require_twin(root)?;
if let Some(h) = host {
if hosts(h).is_empty() || hosts(h).iter().any(|one| one.parse::<std::net::IpAddr>().is_err()) {
bail!("the primary reported replication host {h:?}, which is not an IP address; nothing was installed");
}
}
if replication_port == 0 {
bail!("the primary reported no replication port; there is no mutual listener for this twin to dial");
}
let key_pem = std::fs::read_to_string(root.join(TLS_KEY)).map_err(|err| {
anyhow!(
"this twin has no replication key ({TLS_KEY}: {err}); `Bootstrap.TwinCsr` mints it — \
a certificate cannot be installed for a key that was never made"
)
})?;
let ours = gunnar_core::issuer::public_key_info_of_key(&key_pem)?;
let theirs = gunnar_core::issuer::public_key_info_of_cert(cert_pem)?;
if ours != theirs {
bail!(
"this certificate was issued for a DIFFERENT key than the one this twin minted, so it \
could never complete a handshake here. Nothing was installed. Re-run `gunnar control \
twin certify` against this twin — the request it signs is this box's own."
);
}
if let Err((named, handed)) = gunnar_core::issuer::issued_by(cert_pem, ca_pem)? {
bail!(
"the certificate names issuer `{named}` and the CA handed with it is `{handed}`: this \
twin would pin a CA that did not sign its own credential"
);
}
let cert_der = gunnar_core::issuer::pem_to_der(cert_pem)?;
if let Ok(pinned) = std::fs::read(root.join(TLS_CA)) {
if pinned != ca_pem.as_bytes() {
bail!(
"this twin already pins a DIFFERENT issuer. A twin is never re-pointed, and a door \
that could swap the CA it trusts is a door for redirecting the stream that carries \
the KEK. A renewal comes from the SAME issuer; a new primary needs a fresh twin."
);
}
}
let expiry = gunnar_core::expiry::grade_der(now_ms(), &cert_der)?;
if expiry.is_refusal() {
bail!("{}", expiry.line("the certificate offered to this twin"));
}
crate::durable::write_atomic_unframed(root, TLS_CA, ca_pem.as_bytes())?;
crate::durable::write_atomic_unframed(root, TLS_PORT, replication_port.to_string().as_bytes())?;
match host {
Some(h) => crate::durable::write_atomic_unframed(root, TLS_HOST, h.as_bytes())?,
None => match std::fs::remove_file(root.join(TLS_HOST)) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => return Err(anyhow!("removing {TLS_HOST}: {err}")),
_ => {}
},
}
crate::durable::write_atomic_unframed(root, TLS_CERT, cert_pem.as_bytes())?;
Ok(expiry)
}
pub fn load(root: &Path) -> Result<Option<(gunnar_grpc::client_signer::ClientTrust, u16)>> {
let cert = match std::fs::read(root.join(TLS_CERT)) {
Ok(cert) => cert,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(anyhow!("reading {TLS_CERT}: {err}")),
};
let read = |name: &str| std::fs::read(root.join(name)).with_context(|| format!("{TLS_CERT} is installed but {name} is not readable"));
let key = read(TLS_KEY)?;
let ca = read(TLS_CA)?;
let port: u16 = String::from_utf8_lossy(&read(TLS_PORT)?)
.trim()
.parse()
.map_err(|err| anyhow!("{TLS_PORT} does not hold a port: {err}"))?;
Ok(Some((gunnar_grpc::client_signer::ClientTrust { ca, cert, key }, port)))
}
pub fn load_host(root: &Path) -> Option<String> {
std::fs::read_to_string(root.join(TLS_HOST)).ok().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
}
pub fn record_replication_host(root: &Path, hosts: &[std::net::IpAddr]) -> Result<()> {
if hosts.is_empty() {
return match std::fs::remove_file(root.join(REPLICATION_HOST_FILE)) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => Err(anyhow!("removing {REPLICATION_HOST_FILE}: {err}")),
_ => Ok(()),
};
}
let list = hosts.iter().map(|h| h.to_string()).collect::<Vec<_>>().join(",");
crate::durable::write_atomic_unframed(root, REPLICATION_HOST_FILE, list.as_bytes())
}
pub fn hosts(recorded: &str) -> Vec<String> {
recorded.split(',').map(str::trim).filter(|h| !h.is_empty()).map(str::to_owned).collect()
}
pub fn replication_host(root: &Path) -> Option<String> {
std::fs::read_to_string(root.join(REPLICATION_HOST_FILE)).ok().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
}
pub fn record_replication_port(root: &Path, port: Option<u16>) -> Result<()> {
match port {
Some(port) => crate::durable::write_atomic_unframed(root, REPLICATION_PORT_FILE, port.to_string().as_bytes()),
None => match std::fs::remove_file(root.join(REPLICATION_PORT_FILE)) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => Err(anyhow!("removing {REPLICATION_PORT_FILE}: {err}")),
_ => Ok(()),
},
}
}
pub fn replication_port(root: &Path) -> Option<u16> {
std::fs::read_to_string(root.join(REPLICATION_PORT_FILE)).ok()?.trim().parse().ok()
}
pub fn issue_for_twin(stores: &crate::stores::ServerStores, principal: &str, csr_pem: &str) -> Result<(String, String, u16)> {
let port = replication_port(stores.root()).ok_or_else(|| {
anyhow!(
"this primary serves no mutual replication listener, so a twin certificate would pair it \
to a door that does not exist. Start it with --grpc-replication-bind (it is on by \
default whenever the control plane binds off loopback)"
)
})?;
let groups = stores.auth().groups_of(principal).with_context(|| format!("reading the groups of {principal}"))?;
if !groups.iter().any(|g| g == gunnar_grpc::TWIN_GROUP) {
bail!(
"{principal} is not a member of group `{}`. A replication certificate opens the stream \
that carries every sealed secret; it is issued only to a principal already enrolled as \
a twin (`gunnar control group member add {} {principal}`)",
gunnar_grpc::TWIN_GROUP,
gunnar_grpc::TWIN_GROUP
);
}
let pki = stores.pki()?;
let issuer = pki.issuer()?.ok_or_else(|| anyhow!("this primary has no issuer, so it cannot certify a twin"))?;
let issued = pki.issue_from_csr(principal, csr_pem, gunnar_core::DEFAULT_LEAF_DAYS)?;
let not_after = gunnar_core::issuer::not_after_ms(&issued.cert_der)?;
stores.auth().register_certificate(principal, &issued.cert_der, Some(not_after))?;
let spki = gunnar_core::issuer::public_key_info_of_der(&issued.cert_der)?;
let superseded = stores.auth().supersede_certificates(principal, &spki)?;
if !superseded.is_empty() {
let fingerprints: Vec<&str> = superseded.iter().map(|k| k.fingerprint_sha256.as_str()).collect();
tracing::warn!(
target: gunnar_core::observe::TARGET_TWIN,
principal,
superseded = ?fingerprints,
"a twin certificate for a NEW key was issued: the {} certificate(s) {principal} held for another key are revoked (the box they belonged to is replaced)",
superseded.len()
);
}
Ok((issued.cert_pem, issuer.cert_pem, port))
}
#[cfg(test)]
mod replication_host_tests {
#[test]
fn the_recorded_hosts_are_tunnel_then_utility() {
let dir = tempfile::tempdir().unwrap();
let hosts: Vec<std::net::IpAddr> = vec!["10.44.0.3".parse().unwrap(), "10.13.9.90".parse().unwrap()];
super::record_replication_host(dir.path(), &hosts).unwrap();
let got = super::replication_host(dir.path()).unwrap();
assert_eq!(super::hosts(&got), ["10.44.0.3", "10.13.9.90"]);
assert_eq!(super::hosts("10.44.0.3"), ["10.44.0.3"], "a single host (an older primary) still reads");
super::record_replication_host(dir.path(), &[]).unwrap();
assert_eq!(super::replication_host(dir.path()), None);
}
}