use tokio_rustls::rustls::{
pki_types::{CertificateDer, PrivateKeyDer},
sign::CertifiedKey,
};
#[cfg(feature = "acme")]
pub trait PublishTxt {
fn publish_txt(
&self,
name: &str,
value: &str,
) -> std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), CertError>> + Send + '_>>;
}
#[cfg(feature = "acme")]
impl From<crate::tokio::SetDnsError> for CertError {
fn from(error: crate::tokio::SetDnsError) -> Self {
CertError::Acme(format!("set-dns publish failed: {error}"))
}
}
#[cfg(feature = "acme")]
pub struct SetDnsPublisher<'a> {
config: &'a crate::Config,
node_keystate: &'a ts_keys::NodeState,
}
#[cfg(feature = "acme")]
impl<'a> SetDnsPublisher<'a> {
pub fn new(config: &'a crate::Config, node_keystate: &'a ts_keys::NodeState) -> Self {
Self {
config,
node_keystate,
}
}
}
#[cfg(feature = "acme")]
impl PublishTxt for SetDnsPublisher<'_> {
fn publish_txt(
&self,
name: &str,
value: &str,
) -> std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), CertError>> + Send + '_>>
{
let name = name.to_string();
let value = value.to_string();
Box::pin(async move {
crate::tokio::set_dns(self.config, self.node_keystate, &name, "TXT", &value)
.await
.map_err(CertError::from)
})
}
}
#[cfg(feature = "acme")]
pub async fn issue_certificate_via_setdns(
config: &crate::Config,
node_keystate: &ts_keys::NodeState,
name: &str,
account_key: &crate::acme::AcmeAccountKey,
directory_url: &url::Url,
) -> Result<CertifiedKey, CertError> {
if !is_tailnet_name(name) {
return Err(CertError::NotTailnetName(name.to_string()));
}
let publisher = SetDnsPublisher::new(config, node_keystate);
crate::acme::issue_certificate(name, directory_url, account_key, &publisher).await
}
pub const MISSING_CERT_RPC: &str = "client-side ACME engine (direct to Let's Encrypt) + a POST /machine/set-dns \
Noise RPC to publish the _acme-challenge TXT (a self-hosted control plane returns 501 for set-dns)";
#[derive(Debug)]
pub enum CertError {
Unimplemented {
detail: String,
},
Acme(String),
Io(std::io::Error),
Rustls(tokio_rustls::rustls::Error),
NotTailnetName(String),
}
impl core::fmt::Display for CertError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
CertError::Unimplemented { detail } => {
write!(
f,
"certificate acquisition is unimplemented in this fork: {detail}"
)
}
CertError::Acme(e) => write!(f, "ACME error: {e}"),
CertError::Io(e) => write!(f, "I/O error: {e}"),
CertError::Rustls(e) => write!(f, "rustls error: {e}"),
CertError::NotTailnetName(name) => {
write!(
f,
"refusing to obtain a certificate for non-tailnet name {name:?}"
)
}
}
}
}
impl std::error::Error for CertError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
CertError::Io(e) => Some(e),
CertError::Rustls(e) => Some(e),
CertError::Unimplemented { .. } | CertError::Acme(_) | CertError::NotTailnetName(_) => {
None
}
}
}
}
impl From<std::io::Error> for CertError {
fn from(e: std::io::Error) -> Self {
CertError::Io(e)
}
}
impl From<tokio_rustls::rustls::Error> for CertError {
fn from(e: tokio_rustls::rustls::Error) -> Self {
CertError::Rustls(e)
}
}
pub fn is_tailnet_name(name: &str) -> bool {
let name = name.trim_end_matches('.');
!name.is_empty() && name.ends_with(".ts.net") && !name.contains('/')
}
pub async fn get_certificate(name: &str) -> Result<CertifiedKey, CertError> {
if !is_tailnet_name(name) {
return Err(CertError::NotTailnetName(name.to_string()));
}
Err(CertError::Unimplemented {
detail: format!(
"cannot issue a real certificate for {name:?}; requires: {MISSING_CERT_RPC}"
),
})
}
pub fn certified_key_from_pem(
cert_chain_pem: &[u8],
key_pem: &[u8],
) -> Result<CertifiedKey, CertError> {
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut &cert_chain_pem[..]).collect::<Result<_, _>>()?;
if certs.is_empty() {
return Err(CertError::Acme(
"certificate chain PEM contained no certificates".into(),
));
}
let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut &key_pem[..])?
.ok_or_else(|| CertError::Acme("private key PEM contained no key".into()))?;
certified_key_from_der(certs, key)
}
pub fn certified_key_from_der(
cert_chain: Vec<CertificateDer<'static>>,
key: PrivateKeyDer<'static>,
) -> Result<CertifiedKey, CertError> {
let signing_key = tokio_rustls::rustls::crypto::ring::sign::any_supported_type(&key)
.map_err(CertError::Rustls)?;
let ck = CertifiedKey::new(cert_chain, signing_key);
ck.keys_match().map_err(CertError::Rustls)?;
Ok(ck)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tailnet_name_accepts_magicdns() {
assert!(is_tailnet_name("host.tail1234.ts.net"));
assert!(is_tailnet_name("host.tail1234.ts.net."));
}
#[test]
fn tailnet_name_rejects_offtailnet() {
assert!(!is_tailnet_name("example.com"));
assert!(!is_tailnet_name("evil.ts.net.attacker.com"));
assert!(!is_tailnet_name(""));
assert!(!is_tailnet_name("host.ts.net/path"));
}
#[tokio::test]
async fn get_certificate_is_fail_closed_unimplemented() {
let err = get_certificate("host.tail1234.ts.net")
.await
.expect_err("must not mint a cert without an ACME RPC");
match err {
CertError::Unimplemented { detail } => {
assert!(
detail.contains("cert"),
"detail should name the missing RPC: {detail}"
);
}
other => panic!("expected Unimplemented, got {other:?}"),
}
}
#[tokio::test]
async fn get_certificate_rejects_offtailnet_name() {
let err = get_certificate("example.com").await.unwrap_err();
assert!(matches!(err, CertError::NotTailnetName(_)));
}
#[test]
fn cert_error_is_std_error_and_displays() {
let e = CertError::Unimplemented { detail: "x".into() };
let _: &dyn std::error::Error = &e;
assert!(format!("{e}").contains("unimplemented"));
}
#[cfg(feature = "acme")]
#[tokio::test]
async fn issue_via_setdns_rejects_offtailnet_before_network() {
let config = crate::Config::default();
let keystate = ts_keys::NodeState::generate();
let (account_key, _der) = crate::acme::AcmeAccountKey::generate().expect("generate");
let directory = url::Url::parse(crate::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY).unwrap();
let err = issue_certificate_via_setdns(
&config,
&keystate,
"example.com",
&account_key,
&directory,
)
.await
.expect_err("must refuse a non-tailnet name without touching the network");
assert!(matches!(err, CertError::NotTailnetName(_)));
}
#[cfg(feature = "acme")]
#[test]
fn set_dns_publisher_is_publish_txt() {
fn assert_publish_txt<T: PublishTxt>() {}
assert_publish_txt::<SetDnsPublisher<'_>>();
}
}