use std::{net::SocketAddr, sync::Arc};
use praxis_tls::{CachedCaCerts, CachedClusterTls};
use crate::{
circuit::PeerKey,
connectivity::{
ConnectionOptions, Upstream,
peer::{AddressResolutionError, MissingServerName, resolve_upstream_checked, tls_server_name},
},
};
pub const DEFAULT_POOL_GROUP: u64 = 0;
#[derive(Clone, Debug)]
pub struct PeerSpec {
pub address: SocketAddr,
pub connection: Arc<ConnectionOptions>,
pub pool_group: u64,
pub sni: Arc<str>,
pub tls: bool,
pub tls_config: Option<CachedClusterTls>,
pub trust_anchors: Option<Arc<CachedCaCerts>>,
}
impl PeerSpec {
#[must_use]
pub fn new(address: SocketAddr, tls: bool, sni: &str) -> Self {
Self {
address,
connection: Arc::new(ConnectionOptions::default()),
pool_group: DEFAULT_POOL_GROUP,
sni: Arc::from(sni),
tls,
tls_config: None,
trust_anchors: None,
}
}
#[must_use]
pub fn circuit_key(&self) -> PeerKey {
PeerKey::new(self.address, Arc::clone(&self.sni))
}
pub async fn from_upstream(upstream: &Upstream, allow_private: bool) -> Result<Self, UpstreamPeerError> {
let address = resolve_upstream_checked(upstream, allow_private).await?;
let sni = upstream
.tls
.as_ref()
.map(|tls| tls_server_name(tls, &upstream.address))
.transpose()?
.unwrap_or_default();
Ok(Self {
address,
connection: Arc::clone(&upstream.connection),
pool_group: DEFAULT_POOL_GROUP,
sni: Arc::from(sni.as_str()),
tls: upstream.tls.is_some(),
tls_config: upstream.tls.clone(),
trust_anchors: None,
})
}
#[must_use]
pub fn plain(address: SocketAddr) -> Self {
Self::new(address, false, "")
}
}
#[derive(Debug, thiserror::Error)]
pub enum UpstreamPeerError {
#[error(transparent)]
Resolve(#[from] AddressResolutionError),
#[error(transparent)]
MissingServerName(#[from] MissingServerName),
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, clippy::expect_used, reason = "tests")]
mod tests {
use super::*;
#[test]
fn plain_peer_has_no_tls_and_default_pool_group() {
let peer = PeerSpec::plain("10.0.0.1:80".parse().unwrap());
assert!(!peer.tls, "a plain peer must not use TLS");
assert!(peer.sni.is_empty(), "a plain peer has no server name");
assert_eq!(peer.pool_group, DEFAULT_POOL_GROUP, "default pool partition");
assert!(peer.tls_config.is_none(), "no cluster TLS material");
assert!(peer.trust_anchors.is_none(), "no runtime trust anchors");
}
#[test]
fn circuit_key_includes_the_server_name() {
let peer = PeerSpec::new("10.0.0.1:443".parse().unwrap(), true, "api.example.com");
assert_eq!(
peer.circuit_key(),
PeerKey::new("10.0.0.1:443".parse().unwrap(), "api.example.com"),
"the circuit key must carry address and server name"
);
}
#[tokio::test]
async fn from_upstream_resolves_a_literal_plain_upstream() {
let upstream = Upstream {
address: Arc::from("127.0.0.1:9"),
authority: None,
base_path: None,
connection: Arc::new(ConnectionOptions::default()),
tls: None,
};
let peer = PeerSpec::from_upstream(&upstream, false)
.await
.expect("a literal loopback address bypasses the private-IP check");
assert_eq!(
peer.address,
"127.0.0.1:9".parse::<SocketAddr>().unwrap(),
"the literal address is dialed as given"
);
assert!(!peer.tls, "no cluster TLS means a plain peer");
assert!(
Arc::ptr_eq(&peer.connection, &upstream.connection),
"connection options are shared, not copied"
);
}
}