1use std::net::IpAddr;
2use std::sync::OnceLock;
3
4use super::data::{AI_AGENTS, CRAWLERS, RANGES};
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
8pub enum Group {
9 Person,
11 Ai,
13 Bot,
15 App,
18 Owner,
21 Internal,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
27pub enum Kind {
28 Browser,
30 AiTraining,
31 AiSearch,
32 AiAssistant,
34 AiAgent,
35 SearchEngine,
36 Seo,
37 Monitoring,
38 SocialPreview,
39 Feed,
40 Archiver,
41 Scanner,
43 Spoofed,
45 Hosting,
48 Tool,
50 App,
52 Owner,
55 NoUserAgent,
56 Local,
57}
58
59impl Kind {
60 pub fn group(self) -> Group {
61 match self {
62 Kind::Browser => Group::Person,
63 Kind::AiTraining | Kind::AiSearch | Kind::AiAssistant | Kind::AiAgent => Group::Ai,
64 Kind::Local => Group::Internal,
65 Kind::App => Group::App,
66 Kind::Owner => Group::Owner,
67 _ => Group::Bot,
68 }
69 }
70
71 pub fn label(self) -> &'static str {
72 match self {
73 Kind::Browser => "browser",
74 Kind::AiTraining => "ai-training",
75 Kind::AiSearch => "ai-search",
76 Kind::AiAssistant => "ai-assistant",
77 Kind::AiAgent => "ai-agent",
78 Kind::SearchEngine => "search-engine",
79 Kind::Seo => "seo",
80 Kind::Monitoring => "monitoring",
81 Kind::SocialPreview => "social-preview",
82 Kind::Feed => "feed",
83 Kind::Archiver => "archiver",
84 Kind::Scanner => "scanner",
85 Kind::Spoofed => "spoofed",
86 Kind::Hosting => "hosting",
87 Kind::Tool => "tool",
88 Kind::NoUserAgent => "no-user-agent",
89 Kind::Local => "local",
90 Kind::App => "app",
91 Kind::Owner => "owner",
92 }
93 }
94
95 fn from_label(label: &str) -> Kind {
96 match label {
97 "ai-training" => Kind::AiTraining,
98 "ai-search" => Kind::AiSearch,
99 "ai-assistant" => Kind::AiAssistant,
100 "ai-agent" => Kind::AiAgent,
101 "search-engine" | "fetcher" => Kind::SearchEngine,
102 "seo" => Kind::Seo,
103 "monitoring" => Kind::Monitoring,
104 "social-preview" => Kind::SocialPreview,
105 "feed" => Kind::Feed,
106 "archiver" => Kind::Archiver,
107 "scanner" => Kind::Scanner,
108 "hosting" => Kind::Hosting,
109 _ => Kind::Tool,
110 }
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct Class {
117 pub kind: Kind,
118 pub name: &'static str,
120 pub operator: &'static str,
122 pub verified: bool,
124}
125
126impl Class {
127 pub fn group(&self) -> Group {
128 self.kind.group()
129 }
130
131 fn of(kind: Kind, name: &'static str, operator: &'static str, verified: bool) -> Class {
132 Class { kind, name, operator, verified }
133 }
134}
135
136struct Network {
138 name: &'static str,
139 kind: Kind,
140 token: &'static str,
142 v4: Vec<(u32, u32)>,
143 v6: Vec<(u128, u128)>,
144}
145
146impl Network {
147 fn contains(&self, ip: IpAddr) -> bool {
148 match ip {
149 IpAddr::V4(v4) => {
150 let ip = u32::from(v4);
151 self.v4.iter().any(|(net, mask)| ip & mask == *net)
152 }
153 IpAddr::V6(v6) => {
154 if let Some(v4) = v6.to_ipv4_mapped() {
155 return self.contains(IpAddr::V4(v4));
156 }
157 let ip = u128::from(v6);
158 self.v6.iter().any(|(net, mask)| ip & mask == *net)
159 }
160 }
161 }
162}
163
164fn networks() -> &'static [Network] {
165 static NETWORKS: OnceLock<Vec<Network>> = OnceLock::new();
166 NETWORKS.get_or_init(|| {
167 RANGES
168 .iter()
169 .map(|(name, _group, kind, token, prefixes)| {
170 let mut network = Network { name, kind: Kind::from_label(kind), token, v4: Vec::new(), v6: Vec::new() };
171 for prefix in *prefixes {
172 let Some((address, bits)) = prefix.split_once('/') else { continue };
173 let Ok(bits) = bits.parse::<u32>() else { continue };
174 match address.parse::<IpAddr>() {
175 Ok(IpAddr::V4(a)) if bits <= 32 => {
176 let mask = if bits == 0 { 0 } else { u32::MAX << (32 - bits) };
177 network.v4.push((u32::from(a) & mask, mask));
178 }
179 Ok(IpAddr::V6(a)) if bits <= 128 => {
180 let mask = if bits == 0 { 0 } else { u128::MAX << (128 - bits) };
181 network.v6.push((u128::from(a) & mask, mask));
182 }
183 _ => {}
184 }
185 }
186 network
187 })
188 .collect()
189 })
190}
191
192const SCANNER_PATHS: &[&str] = &[
195 "/.env", "/.git/", "/.aws/", "/.ssh/", "/.svn/", "/.hg/", "/.ds_store", "/wp-login.php", "/wp-admin", "/wp-content/",
196 "/wp-includes/", "/xmlrpc.php", "/phpmyadmin", "/pma/", "/cgi-bin/", "/boaform", "/hnap1", "/actuator/", "/server-status",
197 "/vendor/phpunit", "/.well-known/acme-challenge/../", "/config.json", "/config.php", "/owa/", "/ecp/", "/solr/", "/druid/",
198 "/telescope/", "/_ignition/", "/debug/default/view", "/console/", "/manager/html", "/geoserver/", "/autodiscover/",
199];
200
201pub(super) fn scanner_path(path: &str) -> bool {
202 let path = path.split('?').next().unwrap_or(path);
203 let path: std::borrow::Cow<'_, str> =
205 if path.bytes().any(|b| b.is_ascii_uppercase()) { path.to_ascii_lowercase().into() } else { path.into() };
206 SCANNER_PATHS.iter().any(|p| path.starts_with(p) || path.contains(p))
207 || path.ends_with(".sql")
208 || path.ends_with(".bak")
209}
210
211fn browser(ua: &str) -> bool {
212 ua.starts_with("mozilla/") && ["chrome/", "safari/", "firefox/", "edg/", "opr/"].iter().any(|t| ua.contains(t))
213}
214
215pub fn classify(ip: IpAddr, user_agent: Option<&str>, path: &str) -> Class {
217 if ip.is_loopback() || ip.to_canonical().is_loopback() {
218 return Class::of(Kind::Local, "", "", true);
219 }
220 let ua = user_agent.map(|u| u.trim().to_ascii_lowercase()).unwrap_or_default();
221 let network = networks().iter().find(|n| n.contains(ip));
223 if let Some(network) = network {
224 return Class::of(network.kind, network.name, operator_of(network.name), true);
225 }
226 if let Some(claimed) = networks().iter().find(|n| !n.token.is_empty() && ua.contains(n.token)) {
228 return Class::of(Kind::Spoofed, claimed.name, operator_of(claimed.name), false);
229 }
230 if scanner_path(path) {
231 return Class::of(Kind::Scanner, "", "", false);
232 }
233 if ua.is_empty() {
234 return Class::of(Kind::NoUserAgent, "", "", false);
235 }
236 if let Some((_, name, operator, kind)) = AI_AGENTS.iter().find(|(token, ..)| ua.contains(token)) {
237 return Class::of(Kind::from_label(kind), name, operator, false);
238 }
239 if let Some((_, _, name, kind)) = CRAWLERS.iter().find(|(anchored, literal, ..)| if *anchored { ua.starts_with(literal) } else { ua.contains(literal) }) {
240 return Class::of(Kind::from_label(kind), name, "", false);
241 }
242 if browser(&ua) {
243 return Class::of(Kind::Browser, "", "", false);
244 }
245 Class::of(Kind::Tool, "", "", false)
246}
247
248fn operator_of(network: &str) -> &'static str {
249 match network {
250 "GPTBot" | "OAI-SearchBot" | "ChatGPT-User" => "OpenAI",
251 "PerplexityBot" | "Perplexity-User" => "Perplexity",
252 "CCBot" => "Common Crawl",
253 "Bingbot" => "Microsoft",
254 "Applebot" => "Apple",
255 "DuckDuckBot" => "DuckDuckGo",
256 _ => "Google",
257 }
258}