1use serde::{Deserialize, Serialize};
12
13use crate::dns::DnsResolver;
14use crate::takeover::{match_provider, resolve_host, truncate_to_cap};
15
16const WILDCARD_PROBE_LABEL: &str = "zzzz-seer-wildcard-probe-does-not-exist";
20
21const MAX_CLASSIFY_NAMES: usize = 2000;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum SubdomainStatus {
36 Live,
38 Dead,
40 Wildcard,
43 Unknown,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ClassifiedSubdomain {
51 pub name: String,
52 pub status: SubdomainStatus,
53 #[serde(skip_serializing_if = "Vec::is_empty")]
54 pub addresses: Vec<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub cname: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
60 pub takeover_risk: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SubdomainClassification {
66 pub domain: String,
67 pub wildcard_detected: bool,
69 pub subdomains: Vec<ClassifiedSubdomain>,
70 #[serde(default)]
75 pub names_skipped: usize,
76}
77
78fn classify_one(
80 name: String,
81 addresses: Vec<String>,
82 cname: Option<String>,
83 lookup_failed: bool,
84 wildcard_addrs: &[String],
85) -> ClassifiedSubdomain {
86 let status = if addresses.is_empty() && lookup_failed {
87 SubdomainStatus::Unknown
90 } else if addresses.is_empty() {
91 SubdomainStatus::Dead
92 } else if !wildcard_addrs.is_empty() && addresses.iter().all(|a| wildcard_addrs.contains(a)) {
93 SubdomainStatus::Wildcard
94 } else {
95 SubdomainStatus::Live
96 };
97
98 let takeover_risk = match (&cname, status) {
101 (Some(target), SubdomainStatus::Dead) => {
102 match_provider(target).map(|p| p.provider.to_string())
103 }
104 _ => None,
105 };
106
107 ClassifiedSubdomain {
108 name,
109 status,
110 addresses,
111 cname,
112 takeover_risk,
113 }
114}
115
116pub async fn classify_subdomains(
123 resolver: &DnsResolver,
124 domain: &str,
125 mut names: Vec<String>,
126 concurrency: usize,
127) -> SubdomainClassification {
128 use futures::stream::{self, StreamExt};
129
130 let names_skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
134
135 let probe = format!("{WILDCARD_PROBE_LABEL}.{domain}");
137 let wildcard_addrs = resolve_host(resolver, &probe).await.addresses;
138 let wildcard_detected = !wildcard_addrs.is_empty();
139
140 let concurrency = concurrency.max(1);
141 let wildcard_addrs = std::sync::Arc::new(wildcard_addrs);
142
143 let mut subdomains: Vec<ClassifiedSubdomain> = stream::iter(names)
144 .map(|name| {
145 let wildcard_addrs = wildcard_addrs.clone();
146 async move {
147 let r = resolve_host(resolver, &name).await;
150 let failed = r.lookup_error.is_some();
151 classify_one(name, r.addresses, r.cname, failed, &wildcard_addrs)
152 }
153 })
154 .buffer_unordered(concurrency)
155 .collect()
156 .await;
157
158 subdomains.sort_by(|a, b| {
161 let rank = |s: &ClassifiedSubdomain| {
162 if s.takeover_risk.is_some() {
163 0
164 } else if s.status == SubdomainStatus::Live {
165 1
166 } else {
167 2
168 }
169 };
170 rank(a).cmp(&rank(b)).then_with(|| a.name.cmp(&b.name))
171 });
172
173 SubdomainClassification {
174 domain: domain.to_string(),
175 wildcard_detected,
176 subdomains,
177 names_skipped,
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
190 fn classify_flags_every_takeover_provider() {
191 let dangling = |cname: &str| {
192 classify_one(
193 "gone.example.com".to_string(),
194 vec![],
195 Some(cname.to_string()),
196 false,
197 &[],
198 )
199 .takeover_risk
200 };
201 for p in crate::takeover::PROVIDERS {
202 let suffixes = p.cname_suffixes.iter().map(|s| format!("gone{s}"));
203 let infixes = p.cname_infixes.iter().map(|i| format!("gone{i}.example"));
204 for cname in suffixes.chain(infixes) {
205 assert_eq!(dangling(&cname).as_deref(), Some(p.provider), "{cname}");
206 }
207 }
208 assert_eq!(dangling("cdn.example.com"), None);
210 assert_eq!(dangling("github.io"), None);
211 }
212
213 #[test]
214 fn classify_dead_cname_to_provider_is_takeover_risk() {
215 let c = classify_one(
216 "gone.example.com".to_string(),
217 vec![], Some("gone.herokuapp.com".to_string()),
219 false,
220 &[],
221 );
222 assert_eq!(c.status, SubdomainStatus::Dead);
223 assert_eq!(c.takeover_risk.as_deref(), Some("Heroku"));
224 }
225
226 #[test]
227 fn classify_failed_lookup_is_unknown_not_dangling() {
228 let c = classify_one(
231 "slow.example.com".to_string(),
232 vec![],
233 Some("slow.herokuapp.com".to_string()),
234 true,
235 &[],
236 );
237 assert_eq!(c.status, SubdomainStatus::Unknown);
238 assert!(c.takeover_risk.is_none());
239 let c = classify_one(
241 "v6.example.com".to_string(),
242 vec!["2001:db8::1".to_string()],
243 None,
244 true,
245 &[],
246 );
247 assert_eq!(c.status, SubdomainStatus::Live);
248 }
249
250 #[test]
251 fn classify_live_host_is_not_takeover_even_with_provider_cname() {
252 let c = classify_one(
254 "live.example.com".to_string(),
255 vec!["203.0.113.5".to_string()],
256 Some("live.herokuapp.com".to_string()),
257 false,
258 &[],
259 );
260 assert_eq!(c.status, SubdomainStatus::Live);
261 assert!(c.takeover_risk.is_none());
262 }
263
264 #[test]
265 fn classify_wildcard_addresses_are_marked_wildcard() {
266 let wildcard = vec!["198.51.100.9".to_string()];
267 let c = classify_one(
268 "anything.example.com".to_string(),
269 vec!["198.51.100.9".to_string()],
270 None,
271 false,
272 &wildcard,
273 );
274 assert_eq!(c.status, SubdomainStatus::Wildcard);
275 }
276
277 #[test]
278 fn classify_distinct_address_under_wildcard_is_live() {
279 let wildcard = vec!["198.51.100.9".to_string()];
280 let c = classify_one(
281 "real.example.com".to_string(),
282 vec!["203.0.113.7".to_string()],
283 None,
284 false,
285 &wildcard,
286 );
287 assert_eq!(c.status, SubdomainStatus::Live);
288 }
289
290 #[test]
291 fn classify_cap_keeps_first_n_and_reports_skipped() {
292 let over = MAX_CLASSIFY_NAMES + 37;
294 let mut names: Vec<String> = (0..over).map(|i| format!("h{i}.example.com")).collect();
295 let skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
296 assert_eq!(names.len(), MAX_CLASSIFY_NAMES);
297 assert_eq!(skipped, 37);
298 assert_eq!(names[0], "h0.example.com");
300 }
301
302 #[test]
303 fn classify_cap_is_noop_under_limit() {
304 let mut names: Vec<String> = (0..10).map(|i| format!("h{i}.example.com")).collect();
305 let skipped = truncate_to_cap(&mut names, MAX_CLASSIFY_NAMES);
306 assert_eq!(names.len(), 10);
307 assert_eq!(skipped, 0);
308 }
309}