1use std::collections::HashMap;
73use std::fmt;
74use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
75use std::str::FromStr;
76use std::sync::{Arc, RwLock};
77
78pub const VALID_CLOUD_PROVIDERS: [&str; 6] =
81 ["AWS", "GCP", "Azure", "DigitalOcean", "Linode", "Vultr"];
82
83pub const CARVE_OUT_SEPARATOR: &str = ":!";
86
87#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct CloudConfigError {
91 pub selector: String,
93 pub provider: String,
96}
97
98impl fmt::Display for CloudConfigError {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 let mut valid: Vec<&str> = VALID_CLOUD_PROVIDERS.to_vec();
101 valid.sort_unstable();
102 write!(
103 f,
104 "Unknown cloud providers in block_cloud_providers: [{}]. \
105 Valid: [{}] (a bare name blocks the whole provider; \
106 suffix ':!region' to carve out a region exception)",
107 self.selector,
108 valid.join(", ")
109 )
110 }
111}
112
113impl std::error::Error for CloudConfigError {}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct CloudSelectors {
119 pub blocked: Vec<String>,
121 pub carveouts: HashMap<String, Vec<String>>,
123}
124
125pub fn parse_cloud_selectors<I, S>(selectors: I) -> Result<CloudSelectors, CloudConfigError>
135where
136 I: IntoIterator<Item = S>,
137 S: AsRef<str>,
138{
139 let mut parsed = CloudSelectors::default();
140 for selector in selectors {
141 let selector = selector.as_ref();
142 let (provider, marker, region) = partition_carve_out(selector);
143 if !VALID_CLOUD_PROVIDERS.contains(&provider) {
144 return Err(CloudConfigError {
145 selector: selector.to_owned(),
146 provider: provider.to_owned(),
147 });
148 }
149 if !parsed.blocked.iter().any(|name| name == provider) {
150 parsed.blocked.push(provider.to_owned());
151 }
152 if marker && !region.is_empty() {
153 let regions = parsed.carveouts.entry(provider.to_owned()).or_default();
154 if !regions.iter().any(|known| known == region) {
155 regions.push(region.to_owned());
156 }
157 }
158 }
159 Ok(parsed)
160}
161
162fn partition_carve_out(selector: &str) -> (&str, bool, &str) {
165 match selector.split_once(CARVE_OUT_SEPARATOR) {
166 Some((provider, region)) => (provider, true, region),
167 None => (selector, false, ""),
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174struct Network {
175 addr: IpAddr,
176 prefix: u8,
177}
178
179impl Network {
180 fn parse(text: &str) -> Option<Self> {
183 if let Some((addr_part, prefix_part)) = text.split_once('/') {
184 let addr = IpAddr::from_str(addr_part).ok()?;
185 let prefix = u8::from_str(prefix_part).ok()?;
186 if prefix > family_bits(addr) {
187 return None;
188 }
189 return Some(Self {
190 addr: masked(addr, prefix),
191 prefix,
192 });
193 }
194 let addr = IpAddr::from_str(text).ok()?;
195 Some(Self {
196 addr,
197 prefix: family_bits(addr),
198 })
199 }
200
201 fn contains(&self, addr: IpAddr) -> bool {
203 match (self.addr, addr) {
204 (IpAddr::V4(net), IpAddr::V4(ip)) => {
205 u32::from(net) == (u32::from(ip) & v4_mask(self.prefix))
206 }
207 (IpAddr::V6(net), IpAddr::V6(ip)) => {
208 u128::from(net) == (u128::from(ip) & v6_mask(self.prefix))
209 }
210 _ => false,
211 }
212 }
213}
214
215impl fmt::Display for Network {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 write!(f, "{}/{}", self.addr, self.prefix)
220 }
221}
222
223const fn family_bits(addr: IpAddr) -> u8 {
225 match addr {
226 IpAddr::V4(_) => 32,
227 IpAddr::V6(_) => 128,
228 }
229}
230
231fn masked(addr: IpAddr, prefix: u8) -> IpAddr {
233 match addr {
234 IpAddr::V4(v4) => IpAddr::V4(Ipv4Addr::from(u32::from(v4) & v4_mask(prefix))),
235 IpAddr::V6(v6) => IpAddr::V6(Ipv6Addr::from(u128::from(v6) & v6_mask(prefix))),
236 }
237}
238
239const fn v4_mask(prefix: u8) -> u32 {
240 if prefix == 0 {
241 0
242 } else {
243 u32::MAX << (32 - prefix)
244 }
245}
246
247const fn v6_mask(prefix: u8) -> u128 {
248 if prefix == 0 {
249 0
250 } else {
251 u128::MAX << (128 - prefix)
252 }
253}
254
255#[derive(Debug, Clone, Default)]
258struct ProviderRanges {
259 networks: Vec<Network>,
260 regions: HashMap<String, String>,
261 last_refreshed: Option<i64>,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub struct ProviderStatus {
274 pub ready: bool,
276 pub last_refreshed: Option<i64>,
279 pub entries: usize,
281}
282
283#[derive(Clone, Default)]
291pub struct CloudIpTable {
292 inner: Arc<RwLock<HashMap<String, ProviderRanges>>>,
293}
294
295impl fmt::Debug for CloudIpTable {
296 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297 f.debug_struct("CloudIpTable").finish_non_exhaustive()
298 }
299}
300
301impl CloudIpTable {
302 pub fn set_provider_ranges(
312 &self,
313 provider: &str,
314 entries: Vec<(String, Option<String>)>,
315 ) -> Result<(), (String, CloudRangeError)> {
316 let mut networks = Vec::with_capacity(entries.len());
317 let mut regions = HashMap::new();
318 for (cidr, region) in entries {
319 let network =
320 Network::parse(&cidr).ok_or_else(|| (cidr.clone(), CloudRangeError { cidr }))?;
321 if let Some(region) = region {
322 regions.insert(network.to_string(), region);
323 }
324 networks.push(network);
325 }
326 let now = std::time::SystemTime::now()
327 .duration_since(std::time::UNIX_EPOCH)
328 .map_or(0, |elapsed| i64::try_from(elapsed.as_secs()).unwrap_or(0));
329 self.inner.write().expect("cloud provider table").insert(
330 provider.to_owned(),
331 ProviderRanges {
332 networks,
333 regions,
334 last_refreshed: Some(now),
335 },
336 );
337 Ok(())
338 }
339
340 pub fn clear_provider(&self, provider: &str) {
343 self.inner
344 .write()
345 .expect("cloud provider table")
346 .remove(provider);
347 }
348
349 #[must_use]
352 pub fn provider_is_ready(&self, provider: &str) -> bool {
353 self.inner
354 .read()
355 .expect("cloud provider table")
356 .get(provider)
357 .is_some_and(|ranges| !ranges.networks.is_empty())
358 }
359
360 #[must_use]
365 pub fn provider_status(&self, provider: &str) -> ProviderStatus {
366 self.inner
367 .read()
368 .expect("cloud provider table")
369 .get(provider)
370 .map_or(
371 ProviderStatus {
372 ready: false,
373 last_refreshed: None,
374 entries: 0,
375 },
376 |ranges| ProviderStatus {
377 ready: !ranges.networks.is_empty(),
378 last_refreshed: ranges.last_refreshed,
379 entries: ranges.networks.len(),
380 },
381 )
382 }
383
384 #[allow(clippy::significant_drop_tightening)]
391 #[must_use]
392 pub fn is_cloud_ip(&self, ip: IpAddr, selectors: &CloudSelectors) -> bool {
393 let table = self.inner.read().expect("cloud provider table");
394 selectors.blocked.iter().any(|provider| {
395 let Some(ranges) = table.get(provider) else {
396 return false;
397 };
398 let allowed_regions = selectors.carveouts.get(provider);
399 ranges.networks.iter().any(|network| {
400 if !network.contains(ip) {
401 return false;
402 }
403 let carved_out = allowed_regions.is_some_and(|allowed| {
404 ranges
405 .regions
406 .get(&network.to_string())
407 .is_some_and(|region| allowed.iter().any(|name| name == region))
408 });
409 !carved_out
410 })
411 })
412 }
413
414 #[must_use]
418 pub fn provider_details(
419 &self,
420 ip: IpAddr,
421 selectors: &CloudSelectors,
422 ) -> Option<(String, String)> {
423 let table = self.inner.read().expect("cloud provider table");
424 for provider in &selectors.blocked {
425 if let Some(ranges) = table.get(provider) {
426 for network in &ranges.networks {
427 if network.contains(ip) {
428 return Some((provider.clone(), network.to_string()));
429 }
430 }
431 }
432 }
433 None
434 }
435}
436
437#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct CloudRangeError {
441 pub cidr: String,
443}
444
445impl fmt::Display for CloudRangeError {
446 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447 write!(
448 f,
449 "invalid cloud provider range '{}': expected an IP address or CIDR range",
450 self.cidr
451 )
452 }
453}
454
455impl std::error::Error for CloudRangeError {}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 fn ip(text: &str) -> IpAddr {
462 IpAddr::from_str(text).expect("test address")
463 }
464
465 #[test]
466 fn selectors_parse_bare_and_carve_out_forms() {
467 let selectors = parse_cloud_selectors(["GCP:!us-central1", "AWS", "GCP:!europe-west1"])
468 .expect("valid selectors");
469 assert_eq!(selectors.blocked, vec!["GCP".to_owned(), "AWS".to_owned()]);
470 assert_eq!(
471 selectors.carveouts.get("GCP").expect("carveouts"),
472 &vec!["us-central1".to_owned(), "europe-west1".to_owned()]
473 );
474 assert!(!selectors.carveouts.contains_key("AWS"));
475
476 let selectors = parse_cloud_selectors(["Linode"]).expect("valid selectors");
477 assert_eq!(selectors.blocked, vec!["Linode".to_owned()]);
478 assert!(selectors.carveouts.is_empty());
479 }
480
481 #[test]
482 fn unknown_provider_fails_closed_with_the_reference_message() {
483 let error = parse_cloud_selectors(["GCP", "Hetzner"]).unwrap_err();
484 assert_eq!(error.selector, "Hetzner");
485 assert_eq!(error.provider, "Hetzner");
486 assert_eq!(
487 error.to_string(),
488 "Unknown cloud providers in block_cloud_providers: [Hetzner]. \
489 Valid: [AWS, Azure, DigitalOcean, GCP, Linode, Vultr] \
490 (a bare name blocks the whole provider; \
491 suffix ':!region' to carve out a region exception)"
492 );
493 assert!(parse_cloud_selectors(["aws"]).is_err());
495 let selectors = parse_cloud_selectors(["GCP:!"]).expect("valid selectors");
497 assert_eq!(selectors.blocked, vec!["GCP".to_owned()]);
498 assert!(!selectors.carveouts.contains_key("GCP"));
499 }
500
501 #[test]
502 fn network_parse_clears_host_bits_and_display_is_canonical() {
503 let network = Network::parse("203.0.113.77/24").expect("parses");
504 assert_eq!(network.to_string(), "203.0.113.0/24");
505 let network = Network::parse("203.0.113.7").expect("parses");
507 assert_eq!(network.to_string(), "203.0.113.7/32");
508 let network = Network::parse("2001:db8::5").expect("parses");
509 assert_eq!(network.to_string(), "2001:db8::5/128");
510 }
511
512 #[test]
513 fn network_parse_rejects_junk() {
514 for junk in ["not-an-ip", "203.0.113.0/33", "203.0.113.7/", "/24", ""] {
515 assert!(Network::parse(junk).is_none(), "{junk} must not parse");
516 }
517 }
518
519 #[test]
520 fn membership_is_family_strict() {
521 let network = Network::parse("203.0.113.0/24").expect("v4");
522 assert!(network.contains(ip("203.0.113.9")));
523 assert!(!network.contains(ip("2001:db8::1")));
524 let network = Network::parse("2001:db8::/32").expect("v6");
525 assert!(network.contains(ip("2001:db8::9")));
526 assert!(!network.contains(ip("203.0.113.9")));
527 }
528
529 #[test]
530 fn an_empty_table_blocks_nothing() {
531 let selectors = parse_cloud_selectors(VALID_CLOUD_PROVIDERS).expect("valid selectors");
532 let table = CloudIpTable::default();
533 assert!(!table.is_cloud_ip(ip("203.0.113.9"), &selectors));
534 assert!(!table.provider_is_ready("AWS"));
535 }
536
537 #[test]
538 fn provider_status_answers_the_reference_row_shape() {
539 let table = CloudIpTable::default();
540 assert_eq!(
542 table.provider_status("AWS"),
543 ProviderStatus {
544 ready: false,
545 last_refreshed: None,
546 entries: 0,
547 }
548 );
549
550 table
552 .set_provider_ranges(
553 "AWS",
554 vec![
555 ("203.0.113.0/24".to_owned(), None),
556 ("198.51.100.0/24".to_owned(), Some("us-east-1".to_owned())),
557 ],
558 )
559 .expect("valid ranges");
560 let row = table.provider_status("AWS");
561 assert!(row.ready);
562 assert!(row.last_refreshed.is_some(), "the load stamps the row");
563 assert_eq!(row.entries, 2);
564
565 std::thread::sleep(std::time::Duration::from_millis(1100));
567 table
568 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
569 .expect("valid ranges");
570 let reloaded = table.provider_status("AWS");
571 assert!(
572 reloaded.last_refreshed > row.last_refreshed,
573 "the reload stamps forward"
574 );
575 assert_eq!(reloaded.entries, 1);
576
577 table.clear_provider("AWS");
579 assert_eq!(
580 table.provider_status("AWS"),
581 ProviderStatus {
582 ready: false,
583 last_refreshed: None,
584 entries: 0,
585 }
586 );
587 }
588
589 #[test]
590 fn loaded_provider_blocks_its_ranges() {
591 let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
592 let table = CloudIpTable::default();
593 table
594 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
595 .expect("valid ranges");
596 assert!(table.provider_is_ready("AWS"));
597 assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
598 assert!(!table.is_cloud_ip(ip("203.0.114.9"), &selectors));
599 assert!(!table.is_cloud_ip(ip("2001:db8::9"), &selectors));
600 }
601
602 #[test]
603 fn an_unloaded_selected_provider_is_skipped_not_blocking() {
604 let selectors = parse_cloud_selectors(["GCP", "AWS"]).expect("valid selectors");
605 let table = CloudIpTable::default();
606 table
607 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
608 .expect("valid ranges");
609 assert!(!table.is_cloud_ip(ip("192.0.2.9"), &selectors));
612 assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
613 }
614
615 #[test]
616 fn a_cleared_provider_stops_blocking() {
617 let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
618 let table = CloudIpTable::default();
619 table
620 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
621 .expect("valid ranges");
622 assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
623 table.clear_provider("AWS");
624 assert!(!table.is_cloud_ip(ip("203.0.113.9"), &selectors));
625 assert!(!table.provider_is_ready("AWS"));
626 }
627
628 #[test]
629 fn carve_outs_exempt_only_networks_with_a_known_region() {
630 let selectors =
632 parse_cloud_selectors(["GCP:!us-central1", "GCP:!us-central1"]).expect("valid");
633 assert_eq!(
634 selectors.carveouts.get("GCP").expect("carveouts"),
635 &vec![String::from("us-central1")],
636 "the same carve-out region is not recorded twice"
637 );
638 let table = CloudIpTable::default();
639 table
640 .set_provider_ranges(
641 "GCP",
642 vec![
643 ("198.51.100.0/24".to_owned(), Some("us-central1".to_owned())),
644 (
645 "198.51.101.0/24".to_owned(),
646 Some("europe-west1".to_owned()),
647 ),
648 ],
649 )
650 .expect("valid ranges");
651 assert!(!table.is_cloud_ip(ip("198.51.100.9"), &selectors));
652 assert!(table.is_cloud_ip(ip("198.51.101.9"), &selectors));
653
654 let selectors = parse_cloud_selectors(["Azure:!somewhere"]).expect("valid selectors");
657 table
658 .set_provider_ranges("Azure", vec![("203.0.113.0/24".to_owned(), None)])
659 .expect("valid ranges");
660 assert!(table.is_cloud_ip(ip("203.0.113.9"), &selectors));
661 }
662
663 #[test]
664 fn set_provider_ranges_fails_closed_on_a_junk_entry() {
665 let table = CloudIpTable::default();
666 let error = table
667 .set_provider_ranges("AWS", vec![("not-a-range".to_owned(), None)])
668 .unwrap_err();
669 assert_eq!(error.0, "not-a-range");
670 assert_eq!(
671 error.1.to_string(),
672 "invalid cloud provider range 'not-a-range': expected an IP address or CIDR range"
673 );
674 let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
676 assert!(!table.is_cloud_ip(ip("203.0.113.9"), &selectors));
677 }
678
679 #[test]
680 fn details_report_the_first_matching_selector_provider() {
681 let selectors = parse_cloud_selectors(["GCP", "AWS"]).expect("valid selectors");
682 let table = CloudIpTable::default();
683 table
684 .set_provider_ranges("GCP", vec![("198.51.100.0/24".to_owned(), None)])
685 .expect("valid ranges");
686 table
687 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
688 .expect("valid ranges");
689 assert_eq!(
690 table.provider_details(ip("203.0.113.9"), &selectors),
691 Some(("AWS".to_owned(), "203.0.113.0/24".to_owned()))
692 );
693 assert_eq!(
694 table.provider_details(ip("198.51.100.9"), &selectors),
695 Some(("GCP".to_owned(), "198.51.100.0/24".to_owned()))
696 );
697 assert_eq!(table.provider_details(ip("192.0.2.9"), &selectors), None);
698 }
699
700 #[test]
701 fn clones_share_the_table() {
702 let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
703 let table = CloudIpTable::default();
704 let clone = table.clone();
705 table
706 .set_provider_ranges("AWS", vec![("203.0.113.0/24".to_owned(), None)])
707 .expect("valid ranges");
708 assert!(clone.is_cloud_ip(ip("203.0.113.9"), &selectors));
709 }
710
711 #[test]
712 fn masks_handle_the_zero_prefixes_and_render_debug() {
713 assert_eq!(masked(ip("192.0.2.9"), 0), ip("0.0.0.0"));
714 assert_eq!(masked(ip("192.0.2.9"), 32), ip("192.0.2.9"));
715 assert_eq!(
716 masked(IpAddr::V6("2001:db8::1".parse().expect("v6")), 0),
717 ip("::")
718 );
719 let table = CloudIpTable::default();
720 assert!(format!("{table:?}").contains("CloudIpTable"));
721 }
722}
723
724#[cfg(test)]
725mod gap_tests {
726 use super::*;
727
728 #[test]
729 fn a_provider_without_fed_ranges_has_no_details_and_blocks_nothing() {
730 let table = CloudIpTable::default();
733 let selectors = parse_cloud_selectors(["AWS"]).expect("valid selectors");
734 let probe = "203.0.113.9".parse().expect("ip");
735 assert!(table.provider_details(probe, &selectors).is_none());
736 assert!(!table.is_cloud_ip(probe, &selectors));
737 }
738}