1use std::net::IpAddr;
46use std::sync::Arc;
47
48use async_trait::async_trait;
49use tracing::debug;
50
51use super::policy::{Check, StageSet, Verdict};
52use super::{IdentifierContext, SUBJECT_ONLY_TYPES, canonical};
53use crate::ipam::{AddressNames, IpamRegistry, normalize};
54
55#[derive(Debug)]
58pub struct IpamFilter {
59 ipam: Arc<IpamRegistry>,
60}
61
62impl IpamFilter {
63 #[must_use]
65 pub fn new(ipam: Arc<IpamRegistry>) -> Self {
66 Self { ipam }
67 }
68
69 async fn permitted_names(&self, client_ip: IpAddr) -> Result<AddressNames, Verdict> {
71 let names = self
72 .ipam
73 .names_for(client_ip)
74 .await
75 .map_err(|error| Verdict::Undecided(error.0))?;
76
77 if !names.is_known() {
78 return Err(Verdict::Fail(format!(
79 "{} holds no record of {client_ip}",
80 self.ipam.backend_name()
81 )));
82 }
83
84 Ok(names)
85 }
86}
87
88impl IpamFilter {
89 async fn decide(&self, ctx: &IdentifierContext<'_>) -> Result<(), Verdict> {
90 let client_ip = super::require_client_ip(ctx.client_ip)?;
91 let stage = ctx.stage.as_str();
92 let backend = self.ipam.backend_name();
93
94 if ctx.identifiers.iter().all(is_subject_only) {
98 return Ok(());
99 }
100
101 let permitted = self.permitted_names(client_ip).await?;
102 let names = permitted.names();
103
104 for identifier in ctx.identifiers {
105 if is_subject_only(identifier) {
106 continue;
107 }
108
109 let typ = identifier.typ.to_ascii_lowercase();
110 let value = normalize(&identifier.value);
111
112 match typ.as_str() {
113 "dns" => {
114 if !names.contains(&value) {
115 return Err(Verdict::Fail(format!(
116 "{stage} identifier {} is not among the names {backend} associates \
117 with {client_ip}",
118 identifier.value
119 )));
120 }
121 }
122 "ip" => {
125 let is_client = value
126 .parse::<IpAddr>()
127 .is_ok_and(|ip| canonical(ip) == client_ip);
128 if !is_client && !names.contains(&value) {
129 return Err(Verdict::Fail(format!(
130 "{stage} identifier {} is neither {client_ip} nor a name {backend} \
131 associates with it",
132 identifier.value
133 )));
134 }
135 }
136 other => {
137 return Err(Verdict::Fail(format!(
138 "{stage} requests a {other} identifier, which {backend} cannot confirm \
139 for {client_ip}"
140 )));
141 }
142 }
143 }
144
145 debug!(
146 event = "filter_ipam_accepted",
147 outcome = "success",
148 backend,
149 client_ip = %client_ip,
150 stage,
151 identifiers = ctx.identifiers.len(),
152 );
153 Ok(())
154 }
155}
156
157#[async_trait]
158impl Check for IpamFilter {
159 fn kind(&self) -> &'static str {
160 "ipam"
161 }
162
163 fn stages(&self) -> StageSet {
166 StageSet::identifiers_only()
167 }
168
169 async fn check_identifiers(&self, context: &IdentifierContext<'_>) -> Verdict {
170 self.decide(context).await.err().unwrap_or(Verdict::Pass)
171 }
172}
173
174fn is_subject_only(identifier: &crate::sqlite::order::Identifier) -> bool {
176 SUBJECT_ONLY_TYPES.contains(&identifier.typ.to_ascii_lowercase().as_str())
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::filter::{ConnectionContext, IdentifierStage};
183 use crate::ipam::{Ipam, IpamError};
184 use crate::sqlite::order::Identifier;
185 use crate::testutil::identifiers as ids;
186 use axum::http::Method;
187 use std::sync::atomic::{AtomicUsize, Ordering};
188 use std::time::Duration;
189
190 struct StubIpam {
192 names: Option<Vec<&'static str>>,
193 error: Option<&'static str>,
194 calls: AtomicUsize,
195 }
196
197 impl StubIpam {
198 fn owning(names: &[&'static str]) -> Self {
200 Self {
201 names: Some(names.to_vec()),
202 error: None,
203 calls: AtomicUsize::new(0),
204 }
205 }
206
207 fn unknown() -> Self {
209 Self {
210 names: None,
211 error: None,
212 calls: AtomicUsize::new(0),
213 }
214 }
215
216 fn failing(error: &'static str) -> Self {
217 Self {
218 names: None,
219 error: Some(error),
220 calls: AtomicUsize::new(0),
221 }
222 }
223 }
224
225 #[async_trait]
226 impl Ipam for StubIpam {
227 fn name(&self) -> &'static str {
228 "StubIPAM"
229 }
230
231 async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
232 self.calls.fetch_add(1, Ordering::SeqCst);
233 if let Some(error) = self.error {
234 return Err(IpamError(error.to_string()));
235 }
236 match &self.names {
237 None => Ok(AddressNames::Unknown),
238 Some(names) => {
239 let mut answer = AddressNames::known();
240 for name in names {
241 answer.insert(name);
242 }
243 Ok(answer)
244 }
245 }
246 }
247 }
248
249 fn filter_over(stub: Arc<StubIpam>) -> IpamFilter {
250 IpamFilter::new(Arc::new(IpamRegistry::new(stub, Duration::from_secs(5))))
251 }
252
253 fn filter(stub: StubIpam) -> IpamFilter {
254 filter_over(Arc::new(stub))
255 }
256
257 async fn check_from(
258 filter: &IpamFilter,
259 ip: Option<&str>,
260 identifiers: &[Identifier],
261 ) -> Verdict {
262 filter
263 .check_identifiers(&IdentifierContext {
264 client_ip: ip.map(|ip| ip.parse().unwrap()),
265 account_id: "acct-1",
266 stage: IdentifierStage::NewOrder,
267 identifiers,
268
269 eab: None,
270 })
271 .await
272 }
273
274 async fn check(filter: &IpamFilter, identifiers: &[Identifier]) -> Verdict {
275 check_from(filter, Some("10.0.0.5"), identifiers).await
276 }
277
278 fn assert_denied(verdict: Verdict, needle: &str) {
279 match verdict {
280 Verdict::Fail(detail) => {
281 assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
282 }
283 other => panic!("expected Fail, got {other:?}"),
284 }
285 }
286
287 fn assert_internal(verdict: Verdict, needle: &str) {
288 match verdict {
289 Verdict::Undecided(detail) => {
290 assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
291 }
292 other => panic!("expected Undecided, got {other:?}"),
293 }
294 }
295
296 #[tokio::test]
299 async fn a_listed_name_is_permitted() {
300 let filter = filter(StubIpam::owning(&["host.example.com"]));
301
302 assert_eq!(
303 check(&filter, &ids(&[("dns", "host.example.com")])).await,
304 Verdict::Pass
305 );
306 }
307
308 #[tokio::test]
309 async fn matching_ignores_case_and_a_trailing_dot() {
310 let filter = filter(StubIpam::owning(&["host.example.com"]));
311
312 assert_eq!(
313 check(&filter, &ids(&[("dns", "HOST.example.com.")])).await,
314 Verdict::Pass
315 );
316 }
317
318 #[tokio::test]
319 async fn every_requested_name_must_be_permitted() {
320 let filter = filter(StubIpam::owning(&["a.example.com", "b.example.com"]));
321
322 assert_eq!(
323 check(
324 &filter,
325 &ids(&[("dns", "a.example.com"), ("dns", "b.example.com")]),
326 )
327 .await,
328 Verdict::Pass
329 );
330
331 let error = check(
332 &filter,
333 &ids(&[("dns", "a.example.com"), ("dns", "c.example.com")]),
334 )
335 .await;
336 assert_denied(error, "c.example.com");
337 }
338
339 #[tokio::test]
340 async fn an_ipv4_mapped_client_is_canonicalized_before_the_lookup() {
341 let filter = filter(StubIpam::owning(&["host.example.com"]));
342
343 assert_eq!(
344 check_from(
345 &filter,
346 Some("::ffff:10.0.0.5"),
347 &ids(&[("dns", "host.example.com")]),
348 )
349 .await,
350 Verdict::Pass
351 );
352 }
353
354 #[tokio::test]
359 async fn an_unlisted_name_is_denied_naming_it_the_stage_and_the_backend() {
360 let filter = filter(StubIpam::owning(&["host.example.com"]));
361
362 let error = check(&filter, &ids(&[("dns", "evil.example.com")])).await;
363 assert_denied(error, "newOrder identifier evil.example.com");
364
365 let error = check(&filter, &ids(&[("dns", "evil.example.com")])).await;
366 assert_denied(error, "StubIPAM associates with 10.0.0.5");
367 }
368
369 #[tokio::test]
373 async fn an_unrecorded_address_is_denied_saying_so() {
374 let filter = filter(StubIpam::unknown());
375
376 let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
377 assert_denied(error, "StubIPAM holds no record of 10.0.0.5");
378 }
379
380 #[tokio::test]
381 async fn a_recorded_address_owning_nothing_is_denied_per_name() {
382 let filter = filter(StubIpam::owning(&[]));
383
384 let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
385 assert_denied(error, "is not among the names");
386 }
387
388 #[tokio::test]
389 async fn a_missing_client_address_is_denied() {
390 let filter = filter(StubIpam::owning(&["host.example.com"]));
391
392 let error = check_from(&filter, None, &ids(&[("dns", "host.example.com")])).await;
393 assert_denied(error, "client address unavailable");
394 }
395
396 #[tokio::test]
402 async fn a_failed_lookup_is_internal_not_a_denial() {
403 let filter = filter(StubIpam::failing("HTTP 500"));
404
405 let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
406 assert_internal(error, "HTTP 500");
407 }
408
409 #[tokio::test]
411 async fn a_wedged_inventory_times_out_rather_than_hanging() {
412 struct Hanging;
413 #[async_trait]
414 impl Ipam for Hanging {
415 fn name(&self) -> &'static str {
416 "StubIPAM"
417 }
418 async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
419 tokio::time::sleep(Duration::from_secs(3600)).await;
420 unreachable!("the registry's budget expires first")
421 }
422 }
423
424 let filter = IpamFilter::new(Arc::new(IpamRegistry::new(
425 Arc::new(Hanging),
426 Duration::from_millis(10),
427 )));
428
429 let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
430 assert_internal(error, "timed out after 10ms");
431 }
432
433 #[tokio::test]
436 async fn a_wildcard_needs_the_literal_entry() {
437 let filter = filter(StubIpam::owning(&["example.com"]));
438
439 let error = check(&filter, &ids(&[("dns", "*.example.com")])).await;
440 assert_denied(error, "*.example.com");
441 }
442
443 #[tokio::test]
444 async fn a_literal_wildcard_entry_permits_the_wildcard() {
445 let filter = filter(StubIpam::owning(&["*.example.com"]));
446
447 assert_eq!(
448 check(&filter, &ids(&[("dns", "*.example.com")])).await,
449 Verdict::Pass
450 );
451 }
452
453 #[tokio::test]
454 async fn a_wildcard_entry_does_not_expand_to_subdomains() {
455 let filter = filter(StubIpam::owning(&["*.example.com"]));
456
457 let error = check(&filter, &ids(&[("dns", "a.example.com")])).await;
458 assert_denied(error, "a.example.com");
459 }
460
461 #[tokio::test]
464 async fn the_connecting_address_may_always_be_certified() {
465 let filter = filter(StubIpam::owning(&["host.example.com"]));
466
467 assert_eq!(
468 check(&filter, &ids(&[("ip", "10.0.0.5")])).await,
469 Verdict::Pass
470 );
471 }
472
473 #[tokio::test]
474 async fn another_address_is_denied_unless_listed() {
475 let filter = filter(StubIpam::owning(&["host.example.com"]));
476
477 let error = check(&filter, &ids(&[("ip", "10.0.0.9")])).await;
478 assert_denied(error, "10.0.0.9");
479 }
480
481 #[tokio::test]
482 async fn another_address_may_be_listed_like_any_other_name() {
483 let filter = filter(StubIpam::owning(&["10.0.0.9"]));
484
485 assert_eq!(
486 check(&filter, &ids(&[("ip", "10.0.0.9")])).await,
487 Verdict::Pass
488 );
489 }
490
491 #[tokio::test]
492 async fn a_common_name_is_left_alone() {
493 let filter = filter(StubIpam::owning(&["host.example.com"]));
494
495 assert_eq!(
496 check(
497 &filter,
498 &ids(&[
499 ("dns", "host.example.com"),
500 ("cn", "rcgen self signed cert"),
501 ]),
502 )
503 .await,
504 Verdict::Pass
505 );
506 }
507
508 #[tokio::test]
510 async fn a_request_of_common_names_alone_asks_the_inventory_nothing() {
511 let stub = Arc::new(StubIpam::failing("must not be called"));
512 let filter = filter_over(stub.clone());
513
514 assert_eq!(
515 check(&filter, &ids(&[("cn", "some label")])).await,
516 Verdict::Pass
517 );
518 assert_eq!(stub.calls.load(Ordering::SeqCst), 0);
519 }
520
521 #[tokio::test]
522 async fn a_type_an_inventory_cannot_speak_to_is_denied() {
523 let filter = filter(StubIpam::owning(&["host.example.com"]));
524
525 for typ in ["email", "uri", "other"] {
526 let error = check(&filter, &ids(&[(typ, "whatever")])).await;
527 assert_denied(error, &format!("requests a {typ} identifier"));
528 }
529 }
530
531 #[test]
534 fn reports_its_type_and_stages() {
535 let check = filter(StubIpam::unknown());
536 assert_eq!(check.kind(), "ipam");
537 assert_eq!(check.stages(), StageSet::identifiers_only());
538 }
539
540 #[tokio::test]
541 async fn does_not_inspect_connections() {
542 let stub = Arc::new(StubIpam::failing("must not be called"));
543 let filter = filter_over(stub.clone());
544
545 assert_eq!(
546 filter
547 .check_connection(&ConnectionContext {
548 client_ip: Some("203.0.113.9".parse().unwrap()),
549 method: &Method::POST,
550 path: "/newOrder",
551 })
552 .await,
553 Verdict::Pass
554 );
555 assert_eq!(stub.calls.load(Ordering::SeqCst), 0);
556 }
557
558 #[test]
559 fn the_debug_impl_names_the_backend() {
560 let rendered = format!("{:?}", filter(StubIpam::unknown()));
561 assert!(rendered.contains("StubIPAM"), "{rendered}");
562 }
563}