1use std::net::IpAddr;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use tokio_rustls::rustls::crypto::ring;
24use tokio_rustls::rustls::{ClientConfig, RootCertStore};
25use tracing::warn;
26use waf_state_redis::Reputation;
27
28#[derive(Debug)]
31pub enum FeedError {
32 Io(String),
34 Http(String),
36 Config(String),
38}
39
40impl std::fmt::Display for FeedError {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 Self::Io(e) => write!(f, "feed file read error: {e}"),
44 Self::Http(e) => write!(f, "feed HTTP error: {e}"),
45 Self::Config(e) => write!(f, "feed config error: {e}"),
46 }
47 }
48}
49impl std::error::Error for FeedError {}
50
51#[async_trait]
54#[allow(clippy::double_must_use)]
57pub trait ReputationFeed: Send + Sync {
58 fn name(&self) -> &str;
60 async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError>;
63}
64
65fn parse_ip_list(text: &str, verdict: Reputation, source: &str) -> Vec<(IpAddr, Reputation)> {
74 let mut out = Vec::new();
75 for (idx, raw) in text.lines().enumerate() {
76 let line = raw.split('#').next().unwrap_or("").trim();
78 if line.is_empty() {
79 continue;
80 }
81 let token = line.split_whitespace().next().unwrap_or("");
83 if token.contains('/') {
84 warn!(source, line = idx + 1, entry = token, "reputation feed: CIDR unsupported (v1 exact-IP only), skipping");
85 continue;
86 }
87 match token.parse::<IpAddr>() {
88 Ok(ip) => out.push((ip, verdict)),
89 Err(_) => {
90 warn!(source, line = idx + 1, entry = token, "reputation feed: malformed IP, skipping");
91 }
92 }
93 }
94 out
95}
96
97pub struct FileFeed {
100 path: PathBuf,
101 verdict: Reputation,
102 name: String,
103}
104
105impl FileFeed {
106 pub fn new(path: impl Into<PathBuf>, verdict: Reputation) -> Self {
108 let path = path.into();
109 let name = format!("file:{}", path.display());
110 Self { path, verdict, name }
111 }
112}
113
114#[async_trait]
115impl ReputationFeed for FileFeed {
116 fn name(&self) -> &str {
117 &self.name
118 }
119
120 async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError> {
121 let text = std::fs::read_to_string(&self.path).map_err(|e| FeedError::Io(e.to_string()))?;
124 Ok(parse_ip_list(&text, self.verdict, &self.name))
125 }
126}
127
128pub struct HttpListFeed {
132 url: String,
133 verdict: Reputation,
134 name: String,
135 http: reqwest::Client,
136}
137
138impl HttpListFeed {
139 pub fn new(url: impl Into<String>, verdict: Reputation) -> Result<Self, FeedError> {
142 let mut roots = RootCertStore::empty();
143 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
144 let tls = ClientConfig::builder_with_provider(Arc::new(ring::default_provider()))
146 .with_safe_default_protocol_versions()
147 .map_err(|e| FeedError::Config(format!("rustls client config: {e}")))?
148 .with_root_certificates(roots)
149 .with_no_client_auth();
150 let http = reqwest::Client::builder()
151 .use_preconfigured_tls(tls)
152 .build()
153 .map_err(|e| FeedError::Config(format!("http client: {e}")))?;
154 let url = url.into();
155 let name = format!("http:{url}");
156 Ok(Self { url, verdict, name, http })
157 }
158}
159
160#[async_trait]
161impl ReputationFeed for HttpListFeed {
162 fn name(&self) -> &str {
163 &self.name
164 }
165
166 async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError> {
167 let resp = self
168 .http
169 .get(&self.url)
170 .send()
171 .await
172 .map_err(|e| FeedError::Http(e.to_string()))?
173 .error_for_status()
174 .map_err(|e| FeedError::Http(e.to_string()))?;
175 let text = resp.text().await.map_err(|e| FeedError::Http(format!("read body: {e}")))?;
176 Ok(parse_ip_list(&text, self.verdict, &self.name))
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn parses_ips_skipping_comments_blanks_cidr_and_junk() {
186 let list = "\
187# a header comment
1881.2.3.4
189 5.6.7.8 # trailing comment
1902001:db8::1
191
19210.0.0.0/8
1939.9.9.9 some-label
194not-an-ip
195";
196 let got = parse_ip_list(list, Reputation::Malicious, "test");
197 let ips: Vec<IpAddr> = got.iter().map(|(ip, _)| *ip).collect();
198 assert_eq!(
199 ips,
200 vec![
201 "1.2.3.4".parse::<IpAddr>().unwrap(),
202 "5.6.7.8".parse().unwrap(),
203 "2001:db8::1".parse().unwrap(),
204 "9.9.9.9".parse().unwrap(),
205 ],
206 "comments/blanks/CIDR/junk skipped; IP-with-label kept"
207 );
208 assert!(got.iter().all(|(_, v)| *v == Reputation::Malicious), "verdict is per-source (F5)");
209 }
210
211 #[test]
212 fn empty_or_all_junk_yields_no_entries() {
213 assert!(parse_ip_list("", Reputation::Suspicious, "t").is_empty());
214 assert!(parse_ip_list("# just a comment\n\n \n", Reputation::Suspicious, "t").is_empty());
215 assert!(parse_ip_list("garbage\n1.2.3/8\n", Reputation::Suspicious, "t").is_empty());
216 }
217
218 #[tokio::test]
219 async fn file_feed_reads_and_parses() {
220 let dir = std::env::temp_dir();
221 let path = dir.join(format!("waf-feed-test-{}.txt", std::process::id()));
222 std::fs::write(&path, "1.1.1.1\n# c\n2.2.2.2\n").unwrap();
223
224 let feed = FileFeed::new(&path, Reputation::Suspicious);
225 let entries = feed.fetch().await.expect("reads the file");
226 std::fs::remove_file(&path).ok();
227
228 assert_eq!(entries.len(), 2);
229 assert!(entries.iter().all(|(_, v)| *v == Reputation::Suspicious));
230 assert!(feed.name().starts_with("file:"));
231 }
232
233 #[tokio::test]
234 async fn file_feed_missing_file_is_an_io_error() {
235 let feed = FileFeed::new("/no/such/path/definitely-missing.list", Reputation::Malicious);
236 assert!(matches!(feed.fetch().await, Err(FeedError::Io(_))));
237 }
238
239 #[test]
240 fn http_feed_builds_with_ring_tls() {
241 let feed = HttpListFeed::new("https://example.invalid/list.txt", Reputation::Malicious)
243 .expect("client builds");
244 assert_eq!(feed.name(), "http:https://example.invalid/list.txt");
245 }
246}