use std::net::IpAddr;
use std::sync::Arc;
use async_trait::async_trait;
use tracing::debug;
use super::policy::{Check, StageSet, Verdict};
use super::{IdentifierContext, SUBJECT_ONLY_TYPES, canonical};
use crate::ipam::{AddressNames, IpamRegistry, normalize};
#[derive(Debug)]
pub struct IpamFilter {
ipam: Arc<IpamRegistry>,
}
impl IpamFilter {
#[must_use]
pub fn new(ipam: Arc<IpamRegistry>) -> Self {
Self { ipam }
}
async fn permitted_names(&self, client_ip: IpAddr) -> Result<AddressNames, Verdict> {
let names = self
.ipam
.names_for(client_ip)
.await
.map_err(|error| Verdict::Undecided(error.0))?;
if !names.is_known() {
return Err(Verdict::Fail(format!(
"{} holds no record of {client_ip}",
self.ipam.backend_name()
)));
}
Ok(names)
}
}
impl IpamFilter {
async fn decide(&self, ctx: &IdentifierContext<'_>) -> Result<(), Verdict> {
let client_ip = super::require_client_ip(ctx.client_ip)?;
let stage = ctx.stage.as_str();
let backend = self.ipam.backend_name();
if ctx.identifiers.iter().all(is_subject_only) {
return Ok(());
}
let permitted = self.permitted_names(client_ip).await?;
let names = permitted.names();
for identifier in ctx.identifiers {
if is_subject_only(identifier) {
continue;
}
let typ = identifier.typ.to_ascii_lowercase();
let value = normalize(&identifier.value);
match typ.as_str() {
"dns" => {
if !names.contains(&value) {
return Err(Verdict::Fail(format!(
"{stage} identifier {} is not among the names {backend} associates \
with {client_ip}",
identifier.value
)));
}
}
"ip" => {
let is_client = value
.parse::<IpAddr>()
.is_ok_and(|ip| canonical(ip) == client_ip);
if !is_client && !names.contains(&value) {
return Err(Verdict::Fail(format!(
"{stage} identifier {} is neither {client_ip} nor a name {backend} \
associates with it",
identifier.value
)));
}
}
other => {
return Err(Verdict::Fail(format!(
"{stage} requests a {other} identifier, which {backend} cannot confirm \
for {client_ip}"
)));
}
}
}
debug!(
event = "filter_ipam_accepted",
outcome = "success",
backend,
client_ip = %client_ip,
stage,
identifiers = ctx.identifiers.len(),
);
Ok(())
}
}
#[async_trait]
impl Check for IpamFilter {
fn kind(&self) -> &'static str {
"ipam"
}
fn stages(&self) -> StageSet {
StageSet::identifiers_only()
}
async fn check_identifiers(&self, context: &IdentifierContext<'_>) -> Verdict {
self.decide(context).await.err().unwrap_or(Verdict::Pass)
}
}
fn is_subject_only(identifier: &crate::sqlite::order::Identifier) -> bool {
SUBJECT_ONLY_TYPES.contains(&identifier.typ.to_ascii_lowercase().as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filter::{ConnectionContext, IdentifierStage};
use crate::ipam::{Ipam, IpamError};
use crate::sqlite::order::Identifier;
use crate::testutil::identifiers as ids;
use axum::http::Method;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
struct StubIpam {
names: Option<Vec<&'static str>>,
error: Option<&'static str>,
calls: AtomicUsize,
}
impl StubIpam {
fn owning(names: &[&'static str]) -> Self {
Self {
names: Some(names.to_vec()),
error: None,
calls: AtomicUsize::new(0),
}
}
fn unknown() -> Self {
Self {
names: None,
error: None,
calls: AtomicUsize::new(0),
}
}
fn failing(error: &'static str) -> Self {
Self {
names: None,
error: Some(error),
calls: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl Ipam for StubIpam {
fn name(&self) -> &'static str {
"StubIPAM"
}
async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
self.calls.fetch_add(1, Ordering::SeqCst);
if let Some(error) = self.error {
return Err(IpamError(error.to_string()));
}
match &self.names {
None => Ok(AddressNames::Unknown),
Some(names) => {
let mut answer = AddressNames::known();
for name in names {
answer.insert(name);
}
Ok(answer)
}
}
}
}
fn filter_over(stub: Arc<StubIpam>) -> IpamFilter {
IpamFilter::new(Arc::new(IpamRegistry::new(stub, Duration::from_secs(5))))
}
fn filter(stub: StubIpam) -> IpamFilter {
filter_over(Arc::new(stub))
}
async fn check_from(
filter: &IpamFilter,
ip: Option<&str>,
identifiers: &[Identifier],
) -> Verdict {
filter
.check_identifiers(&IdentifierContext {
client_ip: ip.map(|ip| ip.parse().unwrap()),
account_id: "acct-1",
stage: IdentifierStage::NewOrder,
identifiers,
eab: None,
})
.await
}
async fn check(filter: &IpamFilter, identifiers: &[Identifier]) -> Verdict {
check_from(filter, Some("10.0.0.5"), identifiers).await
}
fn assert_denied(verdict: Verdict, needle: &str) {
match verdict {
Verdict::Fail(detail) => {
assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
}
other => panic!("expected Fail, got {other:?}"),
}
}
fn assert_internal(verdict: Verdict, needle: &str) {
match verdict {
Verdict::Undecided(detail) => {
assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
}
other => panic!("expected Undecided, got {other:?}"),
}
}
#[tokio::test]
async fn a_listed_name_is_permitted() {
let filter = filter(StubIpam::owning(&["host.example.com"]));
assert_eq!(
check(&filter, &ids(&[("dns", "host.example.com")])).await,
Verdict::Pass
);
}
#[tokio::test]
async fn matching_ignores_case_and_a_trailing_dot() {
let filter = filter(StubIpam::owning(&["host.example.com"]));
assert_eq!(
check(&filter, &ids(&[("dns", "HOST.example.com.")])).await,
Verdict::Pass
);
}
#[tokio::test]
async fn every_requested_name_must_be_permitted() {
let filter = filter(StubIpam::owning(&["a.example.com", "b.example.com"]));
assert_eq!(
check(
&filter,
&ids(&[("dns", "a.example.com"), ("dns", "b.example.com")]),
)
.await,
Verdict::Pass
);
let error = check(
&filter,
&ids(&[("dns", "a.example.com"), ("dns", "c.example.com")]),
)
.await;
assert_denied(error, "c.example.com");
}
#[tokio::test]
async fn an_ipv4_mapped_client_is_canonicalized_before_the_lookup() {
let filter = filter(StubIpam::owning(&["host.example.com"]));
assert_eq!(
check_from(
&filter,
Some("::ffff:10.0.0.5"),
&ids(&[("dns", "host.example.com")]),
)
.await,
Verdict::Pass
);
}
#[tokio::test]
async fn an_unlisted_name_is_denied_naming_it_the_stage_and_the_backend() {
let filter = filter(StubIpam::owning(&["host.example.com"]));
let error = check(&filter, &ids(&[("dns", "evil.example.com")])).await;
assert_denied(error, "newOrder identifier evil.example.com");
let error = check(&filter, &ids(&[("dns", "evil.example.com")])).await;
assert_denied(error, "StubIPAM associates with 10.0.0.5");
}
#[tokio::test]
async fn an_unrecorded_address_is_denied_saying_so() {
let filter = filter(StubIpam::unknown());
let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
assert_denied(error, "StubIPAM holds no record of 10.0.0.5");
}
#[tokio::test]
async fn a_recorded_address_owning_nothing_is_denied_per_name() {
let filter = filter(StubIpam::owning(&[]));
let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
assert_denied(error, "is not among the names");
}
#[tokio::test]
async fn a_missing_client_address_is_denied() {
let filter = filter(StubIpam::owning(&["host.example.com"]));
let error = check_from(&filter, None, &ids(&[("dns", "host.example.com")])).await;
assert_denied(error, "client address unavailable");
}
#[tokio::test]
async fn a_failed_lookup_is_internal_not_a_denial() {
let filter = filter(StubIpam::failing("HTTP 500"));
let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
assert_internal(error, "HTTP 500");
}
#[tokio::test]
async fn a_wedged_inventory_times_out_rather_than_hanging() {
struct Hanging;
#[async_trait]
impl Ipam for Hanging {
fn name(&self) -> &'static str {
"StubIPAM"
}
async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
tokio::time::sleep(Duration::from_secs(3600)).await;
unreachable!("the registry's budget expires first")
}
}
let filter = IpamFilter::new(Arc::new(IpamRegistry::new(
Arc::new(Hanging),
Duration::from_millis(10),
)));
let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
assert_internal(error, "timed out after 10ms");
}
#[tokio::test]
async fn a_wildcard_needs_the_literal_entry() {
let filter = filter(StubIpam::owning(&["example.com"]));
let error = check(&filter, &ids(&[("dns", "*.example.com")])).await;
assert_denied(error, "*.example.com");
}
#[tokio::test]
async fn a_literal_wildcard_entry_permits_the_wildcard() {
let filter = filter(StubIpam::owning(&["*.example.com"]));
assert_eq!(
check(&filter, &ids(&[("dns", "*.example.com")])).await,
Verdict::Pass
);
}
#[tokio::test]
async fn a_wildcard_entry_does_not_expand_to_subdomains() {
let filter = filter(StubIpam::owning(&["*.example.com"]));
let error = check(&filter, &ids(&[("dns", "a.example.com")])).await;
assert_denied(error, "a.example.com");
}
#[tokio::test]
async fn the_connecting_address_may_always_be_certified() {
let filter = filter(StubIpam::owning(&["host.example.com"]));
assert_eq!(
check(&filter, &ids(&[("ip", "10.0.0.5")])).await,
Verdict::Pass
);
}
#[tokio::test]
async fn another_address_is_denied_unless_listed() {
let filter = filter(StubIpam::owning(&["host.example.com"]));
let error = check(&filter, &ids(&[("ip", "10.0.0.9")])).await;
assert_denied(error, "10.0.0.9");
}
#[tokio::test]
async fn another_address_may_be_listed_like_any_other_name() {
let filter = filter(StubIpam::owning(&["10.0.0.9"]));
assert_eq!(
check(&filter, &ids(&[("ip", "10.0.0.9")])).await,
Verdict::Pass
);
}
#[tokio::test]
async fn a_common_name_is_left_alone() {
let filter = filter(StubIpam::owning(&["host.example.com"]));
assert_eq!(
check(
&filter,
&ids(&[
("dns", "host.example.com"),
("cn", "rcgen self signed cert"),
]),
)
.await,
Verdict::Pass
);
}
#[tokio::test]
async fn a_request_of_common_names_alone_asks_the_inventory_nothing() {
let stub = Arc::new(StubIpam::failing("must not be called"));
let filter = filter_over(stub.clone());
assert_eq!(
check(&filter, &ids(&[("cn", "some label")])).await,
Verdict::Pass
);
assert_eq!(stub.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn a_type_an_inventory_cannot_speak_to_is_denied() {
let filter = filter(StubIpam::owning(&["host.example.com"]));
for typ in ["email", "uri", "other"] {
let error = check(&filter, &ids(&[(typ, "whatever")])).await;
assert_denied(error, &format!("requests a {typ} identifier"));
}
}
#[test]
fn reports_its_type_and_stages() {
let check = filter(StubIpam::unknown());
assert_eq!(check.kind(), "ipam");
assert_eq!(check.stages(), StageSet::identifiers_only());
}
#[tokio::test]
async fn does_not_inspect_connections() {
let stub = Arc::new(StubIpam::failing("must not be called"));
let filter = filter_over(stub.clone());
assert_eq!(
filter
.check_connection(&ConnectionContext {
client_ip: Some("203.0.113.9".parse().unwrap()),
method: &Method::POST,
path: "/newOrder",
})
.await,
Verdict::Pass
);
assert_eq!(stub.calls.load(Ordering::SeqCst), 0);
}
#[test]
fn the_debug_impl_names_the_backend() {
let rendered = format!("{:?}", filter(StubIpam::unknown()));
assert!(rendered.contains("StubIPAM"), "{rendered}");
}
}