use std::sync::Arc;
use tonic::transport::Channel;
pub use tonic::transport::Endpoint;
pub type ControlChannel = tonic::service::interceptor::InterceptedService<Channel, CredentialSigner>;
#[derive(Clone)]
pub struct SigningKey(pub Arc<ssh_key::PrivateKey>);
#[derive(Clone, Copy, Debug)]
pub struct CredentialSigner;
impl tonic::service::Interceptor for CredentialSigner {
fn call(&mut self, mut request: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
use rand::Rng as _;
let Some(SigningKey(key)) = request.extensions().get::<SigningKey>().cloned() else {
return Ok(request);
};
let method = request
.extensions()
.get::<tonic::GrpcMethod>()
.map(|m| {
let service = m.service().rsplit('.').next().unwrap_or(m.service());
format!("{service}.{}", m.method())
})
.ok_or_else(|| {
tonic::Status::internal(
"this call carries a signing key but no tonic::GrpcMethod, so the credential \
cannot be bound to the method it is about to be spent on",
)
})?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| tonic::Status::internal("the system clock is before 1970"))?
.as_millis() as i64;
let mut bytes = [0u8; 24];
rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(&mut bytes);
let nonce = bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
let credential = if crate::security_key::is_security_key(key.public_key()) {
crate::security_key::sign_credential_with_agent(key.public_key(), now, &nonce, &method)
.map_err(tonic::Status::failed_precondition)?
} else {
crate::sign_credential_for(&key, now, &nonce, &method)
.map_err(|err| tonic::Status::internal(format!("signing the control-plane credential: {err}")))?
};
let value = credential
.to_header_value()
.parse()
.map_err(|_| tonic::Status::internal("the credential is not a legal header value"))?;
request.metadata_mut().insert(crate::CREDENTIAL_METADATA_KEY, value);
Ok(request)
}
}
pub fn authed<T>(key: &ssh_key::PrivateKey, message: T) -> tonic::Request<T> {
let mut request = tonic::Request::new(message);
request.extensions_mut().insert(SigningKey(Arc::new(key.clone())));
request
}
pub fn plain_channel(at: &str) -> Result<ControlChannel, String> {
plain_channel_with(at, |e| e)
}
pub fn keep_alive(every: std::time::Duration, within: std::time::Duration) -> impl FnOnce(tonic::transport::Endpoint) -> tonic::transport::Endpoint {
move |e| e.http2_keep_alive_interval(every).keep_alive_timeout(within).keep_alive_while_idle(true)
}
pub fn plain_channel_with(at: &str, tune: impl FnOnce(tonic::transport::Endpoint) -> tonic::transport::Endpoint) -> Result<ControlChannel, String> {
refuse_if_off_box(at)?;
let url = if at.contains("://") { at.to_owned() } else { format!("http://{at}") };
let endpoint = Channel::from_shared(url)
.map_err(|e| format!("{at:?} is not an address the control plane can be reached at: {e}"))?;
let channel = tune(endpoint).connect_lazy();
Ok(tonic::service::interceptor::InterceptedService::new(channel, CredentialSigner))
}
fn host_of(at: &str) -> &str {
let rest = match at.split_once("://") {
Some((_, rest)) => rest,
None => at,
};
let rest = rest.split('/').next().unwrap_or(rest);
if let Some(inner) = rest.strip_prefix('[') {
return inner.split(']').next().unwrap_or(inner);
}
match rest.rsplit_once(':') {
Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => host,
_ => rest,
}
}
pub const REPLICATION_SERVER_NAME: &str = "gunnar-replication";
pub fn replication_address(control: &str) -> Result<String, String> {
let rest = match control.split_once("://") {
Some((_, rest)) => rest,
None => control,
};
let host = host_of(control);
let port: u16 = rest
.rsplit_once(':')
.and_then(|(_, p)| p.trim_end_matches('/').parse().ok())
.ok_or_else(|| format!("{control:?} names no port, so there is no mutual listener beside it to derive"))?;
let port = port
.checked_add(1)
.ok_or_else(|| format!("{control:?} is on the last port there is; nothing can sit at port + 1"))?;
Ok(if host.contains(':') { format!("[{host}]:{port}") } else { format!("{host}:{port}") })
}
pub fn tls_domain_for(at: &str) -> String {
host_of(at).to_owned()
}
pub fn is_loopback_target(at: &str) -> bool {
let host = host_of(at);
if host.eq_ignore_ascii_case("localhost") {
return true;
}
host.parse::<std::net::IpAddr>().map(|ip| ip.is_loopback()).unwrap_or(false)
}
fn refuse_if_off_box(at: &str) -> Result<(), String> {
if is_loopback_target(at) {
return Ok(());
}
Err(format!(
"{at:?} is not on this box, and a gunnar-to-gunnar call NEVER leaves a machine in the \
clear. The transaction log this channel carries holds the estate's sealed secrets and \
the KEK as seq 1, and the control-plane credential it is signed with is replayable by \
anyone on the path. Dial it with `mutual_channel` and the client certificate, key and \
issuer PEM this box was enrolled with; over WireGuard, as the estate requires. \
`plain_channel` serves loopback only."
))
}
#[derive(Debug, Clone)]
pub struct ClientTrust {
pub ca: Vec<u8>,
pub cert: Vec<u8>,
pub key: Vec<u8>,
}
pub const CLIENT_CERT_ENV: &str = "GUNNAR_CLIENT_CERT";
pub const CLIENT_KEY_ENV: &str = "GUNNAR_CLIENT_KEY";
pub const CA_ENV: &str = "GUNNAR_CA";
impl ClientTrust {
pub fn from_env() -> Result<Option<ClientTrust>, String> {
let read = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty());
let (cert, key, ca) = (read(CLIENT_CERT_ENV), read(CLIENT_KEY_ENV), read(CA_ENV));
if cert.is_none() && key.is_none() && ca.is_none() {
return Ok(None);
}
let want = |value: Option<std::ffi::OsString>, name: &str| {
let path = value.ok_or_else(|| {
format!(
"mutual TLS to another box needs all three of {CLIENT_CERT_ENV}, \
{CLIENT_KEY_ENV} and {CA_ENV}; {name} is not set. `gunnar control bootstrap` \
writes all three files."
)
})?;
let path = std::path::PathBuf::from(path);
std::fs::read(&path).map_err(|err| format!("reading {}: {err}", path.display()))
};
Ok(Some(ClientTrust {
cert: want(cert, CLIENT_CERT_ENV)?,
key: want(key, CLIENT_KEY_ENV)?,
ca: want(ca, CA_ENV)?,
}))
}
}
pub fn mutual_channel(at: &str, trust: &ClientTrust, domain: &str) -> Result<ControlChannel, String> {
mutual_channel_with(at, trust, domain, |e| e)
}
pub fn mutual_channel_with(at: &str, trust: &ClientTrust, domain: &str, tune: impl FnOnce(tonic::transport::Endpoint) -> tonic::transport::Endpoint) -> Result<ControlChannel, String> {
crate::ensure_crypto_provider();
crate::refuse_unless_tls13_only().map_err(|err| err.to_string())?;
let url = if at.contains("://") { at.to_owned() } else { format!("https://{at}") };
if url.starts_with("http://") {
return Err(format!(
"{url} names plaintext http:// on a link that must be mutually authenticated. Drop \
the scheme, or write https://."
));
}
let tls = tonic::transport::ClientTlsConfig::new()
.domain_name(domain)
.ca_certificate(tonic::transport::Certificate::from_pem(&trust.ca))
.identity(tonic::transport::Identity::from_pem(&trust.cert, &trust.key));
let endpoint = Channel::from_shared(url)
.map_err(|e| format!("{at:?} is not an address the control plane can be reached at: {e}"))?
.tls_config(tls)
.map_err(|e| format!("configuring mutual TLS for the channel to {at:?}: {e}"))?;
let channel = tune(endpoint).connect_lazy();
Ok(tonic::service::interceptor::InterceptedService::new(channel, CredentialSigner))
}