use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use tokio_rustls::rustls::crypto::ring;
use tokio_rustls::rustls::{ClientConfig, RootCertStore};
use tracing::warn;
use waf_state_redis::Reputation;
#[derive(Debug)]
pub enum FeedError {
Io(String),
Http(String),
Config(String),
}
impl std::fmt::Display for FeedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "feed file read error: {e}"),
Self::Http(e) => write!(f, "feed HTTP error: {e}"),
Self::Config(e) => write!(f, "feed config error: {e}"),
}
}
}
impl std::error::Error for FeedError {}
#[async_trait]
#[allow(clippy::double_must_use)]
pub trait ReputationFeed: Send + Sync {
fn name(&self) -> &str;
async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError>;
}
fn parse_ip_list(text: &str, verdict: Reputation, source: &str) -> Vec<(IpAddr, Reputation)> {
let mut out = Vec::new();
for (idx, raw) in text.lines().enumerate() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let token = line.split_whitespace().next().unwrap_or("");
if token.contains('/') {
warn!(source, line = idx + 1, entry = token, "reputation feed: CIDR unsupported (v1 exact-IP only), skipping");
continue;
}
match token.parse::<IpAddr>() {
Ok(ip) => out.push((ip, verdict)),
Err(_) => {
warn!(source, line = idx + 1, entry = token, "reputation feed: malformed IP, skipping");
}
}
}
out
}
pub struct FileFeed {
path: PathBuf,
verdict: Reputation,
name: String,
}
impl FileFeed {
pub fn new(path: impl Into<PathBuf>, verdict: Reputation) -> Self {
let path = path.into();
let name = format!("file:{}", path.display());
Self { path, verdict, name }
}
}
#[async_trait]
impl ReputationFeed for FileFeed {
fn name(&self) -> &str {
&self.name
}
async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError> {
let text = std::fs::read_to_string(&self.path).map_err(|e| FeedError::Io(e.to_string()))?;
Ok(parse_ip_list(&text, self.verdict, &self.name))
}
}
pub struct HttpListFeed {
url: String,
verdict: Reputation,
name: String,
http: reqwest::Client,
}
impl HttpListFeed {
pub fn new(url: impl Into<String>, verdict: Reputation) -> Result<Self, FeedError> {
let mut roots = RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls = ClientConfig::builder_with_provider(Arc::new(ring::default_provider()))
.with_safe_default_protocol_versions()
.map_err(|e| FeedError::Config(format!("rustls client config: {e}")))?
.with_root_certificates(roots)
.with_no_client_auth();
let http = reqwest::Client::builder()
.use_preconfigured_tls(tls)
.build()
.map_err(|e| FeedError::Config(format!("http client: {e}")))?;
let url = url.into();
let name = format!("http:{url}");
Ok(Self { url, verdict, name, http })
}
}
#[async_trait]
impl ReputationFeed for HttpListFeed {
fn name(&self) -> &str {
&self.name
}
async fn fetch(&self) -> Result<Vec<(IpAddr, Reputation)>, FeedError> {
let resp = self
.http
.get(&self.url)
.send()
.await
.map_err(|e| FeedError::Http(e.to_string()))?
.error_for_status()
.map_err(|e| FeedError::Http(e.to_string()))?;
let text = resp.text().await.map_err(|e| FeedError::Http(format!("read body: {e}")))?;
Ok(parse_ip_list(&text, self.verdict, &self.name))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ips_skipping_comments_blanks_cidr_and_junk() {
let list = "\
# a header comment
1.2.3.4
5.6.7.8 # trailing comment
2001:db8::1
10.0.0.0/8
9.9.9.9 some-label
not-an-ip
";
let got = parse_ip_list(list, Reputation::Malicious, "test");
let ips: Vec<IpAddr> = got.iter().map(|(ip, _)| *ip).collect();
assert_eq!(
ips,
vec![
"1.2.3.4".parse::<IpAddr>().unwrap(),
"5.6.7.8".parse().unwrap(),
"2001:db8::1".parse().unwrap(),
"9.9.9.9".parse().unwrap(),
],
"comments/blanks/CIDR/junk skipped; IP-with-label kept"
);
assert!(got.iter().all(|(_, v)| *v == Reputation::Malicious), "verdict is per-source (F5)");
}
#[test]
fn empty_or_all_junk_yields_no_entries() {
assert!(parse_ip_list("", Reputation::Suspicious, "t").is_empty());
assert!(parse_ip_list("# just a comment\n\n \n", Reputation::Suspicious, "t").is_empty());
assert!(parse_ip_list("garbage\n1.2.3/8\n", Reputation::Suspicious, "t").is_empty());
}
#[tokio::test]
async fn file_feed_reads_and_parses() {
let dir = std::env::temp_dir();
let path = dir.join(format!("waf-feed-test-{}.txt", std::process::id()));
std::fs::write(&path, "1.1.1.1\n# c\n2.2.2.2\n").unwrap();
let feed = FileFeed::new(&path, Reputation::Suspicious);
let entries = feed.fetch().await.expect("reads the file");
std::fs::remove_file(&path).ok();
assert_eq!(entries.len(), 2);
assert!(entries.iter().all(|(_, v)| *v == Reputation::Suspicious));
assert!(feed.name().starts_with("file:"));
}
#[tokio::test]
async fn file_feed_missing_file_is_an_io_error() {
let feed = FileFeed::new("/no/such/path/definitely-missing.list", Reputation::Malicious);
assert!(matches!(feed.fetch().await, Err(FeedError::Io(_))));
}
#[test]
fn http_feed_builds_with_ring_tls() {
let feed = HttpListFeed::new("https://example.invalid/list.txt", Reputation::Malicious)
.expect("client builds");
assert_eq!(feed.name(), "http:https://example.invalid/list.txt");
}
}