Повышение надёжности проверок, улучшение тестов, микрофиксы (#87)

* test: cover probe verdict combinations

- fix the `sni_block` fixture to use a whitelist host
- cover `whitelist` for responsive blacklist hosts

* fix: handle empty reporter results

- return `0.00%` for empty scans
- add tests for empty and populated summaries

* fix: validate reporter scan options

- reject zero values for `--probes` and `--retry-count`
- increase read timeout on each retry
- cover validation and timeout calculation with tests

* refactor: clean up querying code

- simplify parsing and collection access
- use standard `std::io::Error` helpers
- add `Default` for reusable checker types

* refactor: simplify reporter code

- remove redundant conversions and copies
- simplify CSV parsing and HTTP status output

* refactor: clean up website code

- simplify database access and error conversion
- clean up MQTT config parsing
This commit is contained in:
Tema Smirnov
2026-08-29 20:56:37 +05:00
committed by GitHub
parent 7a89ee272f
commit 75294bb0dd
17 changed files with 212 additions and 110 deletions
+6
View File
@@ -129,6 +129,12 @@ pub struct AsnCache {
cache: Arc<RwLock<HashMap<u32, CachedAsnData>>>,
}
impl Default for AsnCache {
fn default() -> Self {
Self::new()
}
}
impl AsnCache {
pub fn new() -> Self {
Self {
+1 -2
View File
@@ -142,8 +142,7 @@ impl DatabaseCache {
},
],
};
let metadata = serde_json::to_vec_pretty(&metadata)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let metadata = serde_json::to_vec_pretty(&metadata).map_err(io::Error::other)?;
self.write_file(METADATA_FILE, &metadata)
}
+9 -6
View File
@@ -3,7 +3,6 @@ use async_trait::async_trait;
use maxminddb::geoip2::{City, Country};
use maxminddb::{MaxMindDbError, geoip2};
use serde::Serialize;
use std::io;
use std::io::Error;
use std::net::IpAddr;
@@ -34,6 +33,12 @@ impl Default for IpInfo {
}
}
impl Default for GeoIp {
fn default() -> Self {
Self::new()
}
}
impl GeoIp {
pub fn new() -> Self {
GeoIp {
@@ -80,11 +85,10 @@ impl GeoIp {
let country_code = country
.as_ref()
.map(|c| c.country.iso_code)
.flatten()
.and_then(|c| c.country.iso_code)
.map(|c| c.to_string());
let city_geo_name_id = city.as_ref().map(|c| c.city.geoname_id).flatten();
let city_geo_name_id = city.as_ref().and_then(|c| c.city.geoname_id);
let mut location = (None, None);
if let Some(city) = city {
@@ -141,7 +145,6 @@ impl Updatable for GeoIp {
}
async fn install(&mut self, (asn, country, city): Self::Base) -> Result<(), Error> {
self.update(asn, country, city)
.map_err(|e| Error::new(io::ErrorKind::Other, e))
self.update(asn, country, city).map_err(Error::other)
}
}
+8 -9
View File
@@ -100,7 +100,7 @@ impl Checker {
}
};
let reverse_lookup = if let Some(ip) = ips.get(0).cloned() {
let reverse_lookup = if let Some(ip) = ips.first().copied() {
match self.resolver.lookup_ptr(ip).await {
Ok(ptr) => ptr,
Err(e) => {
@@ -113,7 +113,7 @@ impl Checker {
};
let geo_ip = self.geo_ip.load();
let geo = match ips.get(0).map(|ip| geo_ip.lookup(ip.clone())) {
let geo = match ips.first().map(|ip| geo_ip.lookup(*ip)) {
None => IpInfo::default(),
Some(Ok(ip)) => ip,
Some(Err(e)) => {
@@ -172,12 +172,11 @@ impl Checker {
.collect();
for prefix in &prefixes {
if let Ok(ipnet) = prefix.parse::<IpNet>() {
if cdn_list.contains(&ipnet.network()).is_some() {
if !blocked_prefixes.contains(prefix) {
blocked_prefixes.push(prefix.clone());
}
}
if let Ok(ipnet) = prefix.parse::<IpNet>()
&& cdn_list.contains(&ipnet.network()).is_some()
&& !blocked_prefixes.contains(prefix)
{
blocked_prefixes.push(prefix.clone());
}
}
@@ -215,7 +214,7 @@ impl Checker {
}
pub fn last_update(&self) -> Option<DateTime<Utc>> {
self.rx.borrow().clone()
*self.rx.borrow()
}
pub async fn download_all() -> Result<Bases, io::Error> {
+41 -29
View File
@@ -14,6 +14,12 @@ pub struct CdnList {
trie: IpnetTrie<NetworkRecord>,
}
impl Default for CdnList {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Eq, PartialEq, Hash)]
pub struct NetworkRecord {
pub provider: String,
@@ -97,6 +103,12 @@ pub struct RuBlacklist {
pub domain_count: usize,
}
impl Default for RuBlacklist {
fn default() -> Self {
Self::new()
}
}
impl RuBlacklist {
pub fn new() -> RuBlacklist {
RuBlacklist {
@@ -176,6 +188,35 @@ impl RuBlacklist {
}
}
#[async_trait]
impl Updatable for RuBlacklist {
type Base = (VecDeque<u8>, VecDeque<u8>, VecDeque<u8>);
async fn download() -> Result<Self::Base, Error> {
Ok((
VecDeque::from(
fetch_db(Self::get_url(
"RKN_NETS",
"https://antifilter.network/download/ipsum.lst",
))
.await?,
),
VecDeque::from(
fetch_db(Self::get_url(
"RKN_DOMAINS",
"https://antifilter.download/list/domains.lst",
))
.await?,
),
VecDeque::from(include_bytes!("../dist-domains.txt").to_vec()),
))
}
async fn install(&mut self, (nets, domains, custom_domains): Self::Base) -> Result<(), Error> {
self.update(nets, domains, custom_domains)
}
}
#[cfg(test)]
mod tests {
use super::RuBlacklist;
@@ -205,32 +246,3 @@ mod tests {
assert_eq!(list.contains_domain("notblocked.example"), None);
}
}
#[async_trait]
impl Updatable for RuBlacklist {
type Base = (VecDeque<u8>, VecDeque<u8>, VecDeque<u8>);
async fn download() -> Result<Self::Base, Error> {
Ok((
VecDeque::from(
fetch_db(Self::get_url(
"RKN_NETS",
"https://antifilter.network/download/ipsum.lst",
))
.await?,
),
VecDeque::from(
fetch_db(Self::get_url(
"RKN_DOMAINS",
"https://antifilter.download/list/domains.lst",
))
.await?,
),
VecDeque::from(include_bytes!("../dist-domains.txt").to_vec()),
))
}
async fn install(&mut self, (nets, domains, custom_domains): Self::Base) -> Result<(), Error> {
self.update(nets, domains, custom_domains)
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ use hickory_resolver::net::runtime::TokioRuntimeProvider;
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::proto::ProtoError;
use hickory_resolver::proto::rr::RData;
use std::io::{Error, ErrorKind};
use std::io::Error;
use std::net::IpAddr;
use std::sync::Arc;
use thiserror::Error;
@@ -93,6 +93,6 @@ fn map_resolve_error(error: NetError) -> ResolveError {
{
ResolveError::NxDomain
}
_ => ResolveError::Other(Error::new(ErrorKind::Other, error)),
_ => ResolveError::Other(Error::other(error)),
}
}
+22 -21
View File
@@ -17,26 +17,27 @@ pub enum Target {
impl From<&str> for Target {
fn from(input: &str) -> Self {
if input.to_lowercase().starts_with("as") {
if let Ok(asn) = input[2..].parse::<u32>() {
if asn >= 1 {
return Target::Asn(asn);
}
}
if input
.get(..2)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("as"))
&& let Ok(asn) = input[2..].parse::<u32>()
&& asn >= 1
{
return Target::Asn(asn);
}
if input.contains('/') {
if let Ok(ipv4_net) = input.parse::<Ipv4Net>() {
if ipv4_net.prefix_len() >= 8 {
return Target::Ipv4Subnet(ipv4_net);
}
}
if input.contains('/')
&& let Ok(ipv4_net) = input.parse::<Ipv4Net>()
&& ipv4_net.prefix_len() >= 8
{
return Target::Ipv4Subnet(ipv4_net);
}
if let Ok(ipv6_net) = input.parse::<Ipv6Net>() {
if ipv6_net.prefix_len() >= 32 {
return Target::Ipv6Subnet(ipv6_net);
}
}
if input.contains('/')
&& let Ok(ipv6_net) = input.parse::<Ipv6Net>()
&& ipv6_net.prefix_len() >= 32
{
return Target::Ipv6Subnet(ipv6_net);
}
if let Ok(ipv4) = input.parse::<Ipv4Addr>() {
@@ -47,10 +48,10 @@ impl From<&str> for Target {
return Target::Ipv6(ipv6);
}
if let Ok(url) = input.parse::<Url>() {
if let Some(host) = url.host_str() {
return Target::Domain(host.trim_end_matches('.').to_string());
}
if let Ok(url) = input.parse::<Url>()
&& let Some(host) = url.host_str()
{
return Target::Domain(host.trim_end_matches('.').to_string());
}
Target::Domain(input.trim_end_matches('.').to_string())
}
+5 -7
View File
@@ -4,30 +4,28 @@ use indicatif::{ProgressBar, ProgressStyle};
use log::info;
use reqwest::IntoUrl;
use std::fmt::Display;
use std::io;
use std::io::Error;
pub async fn fetch_db<T: IntoUrl + Display>(url: T) -> Result<Vec<u8>, Error> {
info!("Fetching {}", url);
let response = reqwest::get(url)
.await
.map_err(|e| Error::new(io::ErrorKind::Other, e))?
.map_err(Error::other)?
.error_for_status()
.map_err(|e| Error::new(io::ErrorKind::Other, e))?;
.map_err(Error::other)?;
let total_size = response.content_length().unwrap_or(0);
let pb = ProgressBar::new(total_size);
pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.map_err(|e| Error::new(io::ErrorKind::Other, e))?
.map_err(Error::other)?
.progress_chars("#>-"));
let mut bytes = Vec::new();
bytes.reserve(total_size as usize);
let mut bytes = Vec::with_capacity(total_size as usize);
let mut stream = response.bytes_stream();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| Error::new(io::ErrorKind::Other, e))?;
let chunk = chunk_result.map_err(Error::other)?;
bytes.extend(&chunk);
pb.inc(chunk.len() as u64);
}
+40 -5
View File
@@ -17,9 +17,9 @@ pub struct Counter {
impl Counter {
pub fn save_results(&self, output: &PathBuf) -> anyhow::Result<()> {
let mut out = csv::WriterBuilder::new().from_path(output)?;
out.write_record(&["target", "evidence"])?;
out.write_record(["target", "evidence"])?;
for (target, evidence) in &self.results {
out.write_record(&[target, &evidence.to_string()])?;
out.write_record([target, &evidence.to_string()])?;
}
info!("Saved results to {:?}", output);
Ok(())
@@ -59,16 +59,51 @@ impl Counter {
impl Display for Counter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let total = self.total();
let percentage = |count| {
if total == 0 {
0.0
} else {
count as f32 / total as f32 * 100.0
}
};
write!(
f,
"OK {} ({:.2}%) | Blocked {} (early: {}) ({:.2}%) | Error {} ({:.2}%)",
self.ok,
self.ok as f32 / total as f32 * 100.0,
percentage(self.ok),
self.block,
self.early,
self.block as f32 / total as f32 * 100.0,
percentage(self.block),
self.err,
self.err as f32 / total as f32 * 100.0
percentage(self.err)
)
}
}
#[cfg(test)]
mod tests {
use super::Counter;
use reports::Evidence;
#[test]
fn empty_counter_uses_zero_percentages() {
assert_eq!(
Counter::default().to_string(),
"OK 0 (0.00%) | Blocked 0 (early: 0) (0.00%) | Error 0 (0.00%)"
);
}
#[test]
fn counter_tracks_all_evidence_classes() {
let mut counter = Counter::default();
counter.add("ok.example", Evidence::Ok);
counter.add("blocked.example", Evidence::Blocked);
counter.add("error.example", Evidence::ConnectError);
assert_eq!(counter.total(), 3);
assert_eq!(
counter.to_string(),
"OK 1 (33.33%) | Blocked 1 (early: 0) (33.33%) | Error 1 (33.33%)"
);
}
}
+40 -9
View File
@@ -32,6 +32,14 @@ enum Verbosity {
All,
}
fn parse_positive_usize(value: &str) -> Result<usize, String> {
match value.parse::<usize>() {
Ok(value) if value > 0 => Ok(value),
Ok(_) => Err("значение должно быть больше нуля".to_string()),
Err(error) => Err(format!("некорректное целое число: {error}")),
}
}
#[derive(Parser, Debug, Clone)]
#[command(
author,
@@ -57,7 +65,7 @@ struct Args {
timeout_secs: u64,
/// Maximum concurrent probes. Make sure that it doesn't exceed 'ulimit -n'
#[arg(short, long = "probes", default_value_t = 1000)]
#[arg(short, long = "probes", default_value_t = 1000, value_parser = parse_positive_usize)]
probe_count: usize,
/// Display probing results in console
@@ -65,7 +73,7 @@ struct Args {
verbosity: Verbosity,
/// Attempts to establish connection
#[arg(short, long, default_value_t = 2)]
#[arg(short, long, default_value_t = 2, value_parser = parse_positive_usize)]
retry_count: usize,
/// Try using plain HTTP without TLS
@@ -102,7 +110,7 @@ impl Args {
ReporterConfig {
http: self.http,
tx_junk: self.tx,
ip: self.ip.clone(),
ip: self.ip,
path: self.path.clone(),
retry_count: self.retry_count,
timeout_secs: self.timeout_secs,
@@ -117,10 +125,14 @@ fn build_client(args: &Args, attempt: usize) -> reqwest::Result<Client> {
.redirect(Policy::none())
.use_rustls_tls()
.dns_resolver(Arc::new(Resolver::new(args.ip)))
.read_timeout(Duration::from_secs(args.timeout_secs * attempt as u64))
.read_timeout(retry_read_timeout(args.timeout_secs, attempt))
.timeout(Duration::from_secs(15));
Ok(client.build()?)
client.build()
}
fn retry_read_timeout(timeout_secs: u64, attempt: usize) -> Duration {
Duration::from_secs(timeout_secs.saturating_mul(attempt as u64))
}
#[tokio::main]
@@ -145,7 +157,7 @@ async fn main() -> Result<()> {
let targets: Vec<String> = targets
.lines()
.take(args.count)
.map(|s| s.split(",").last().unwrap().to_string())
.map(|s| s.split(',').next_back().unwrap().to_string())
.collect();
info!(
@@ -244,9 +256,9 @@ async fn upload_results(
let uploaded = uploaded.send().await?;
if uploaded.status().is_success() {
info!("Uploaded ({})!", uploaded.status().to_string());
info!("Uploaded ({})!", uploaded.status());
} else {
warn!("Upload failed: {}", uploaded.status().to_string());
warn!("Upload failed: {}", uploaded.status());
}
info!("Agency response: {}", uploaded.text().await?);
Ok(())
@@ -288,7 +300,7 @@ async fn check_target(args: &Args, target: &str) -> Result<Verdict, reqwest::Err
loop {
attempts += 1;
let client = build_client(&args, 1)?;
let client = build_client(args, attempts)?;
let mut resp = client.get(&url).header("Range", "bytes=0-65536");
if args.tx {
resp = resp.body(JUNK)
@@ -341,3 +353,22 @@ async fn check_target(args: &Args, target: &str) -> Result<Verdict, reqwest::Err
};
}
}
#[cfg(test)]
mod tests {
use super::{parse_positive_usize, retry_read_timeout};
use std::time::Duration;
#[test]
fn positive_usize_rejects_zero() {
assert_eq!(parse_positive_usize("1"), Ok(1));
assert!(parse_positive_usize("0").is_err());
assert!(parse_positive_usize("not-a-number").is_err());
}
#[test]
fn retry_timeout_grows_with_attempt_number() {
assert_eq!(retry_read_timeout(5, 1), Duration::from_secs(5));
assert_eq!(retry_read_timeout(5, 3), Duration::from_secs(15));
}
}
+2 -2
View File
@@ -8,14 +8,14 @@ pub struct Resolver {
impl Resolver {
pub fn new(ip: IpAddr) -> Resolver {
Resolver {
ip: SocketAddr::from(SocketAddr::new(ip, 0)),
ip: SocketAddr::new(ip, 0),
}
}
}
impl Resolve for Resolver {
fn resolve(&self, _: Name) -> Resolving {
let ip = self.ip.clone();
let ip = self.ip;
Box::pin(async move { Ok(Addrs::from(Box::new(vec![ip].into_iter()))) })
}
}
+4
View File
@@ -10,6 +10,10 @@ use sqlx::postgres::PgPool;
pub struct Agency {
pub id: i32,
#[expect(
dead_code,
reason = "поле входит в SQLx offline-запрос; его удаление требует обновления метаданных на эталонной PostgreSQL"
)]
pub name: String,
}
+2 -2
View File
@@ -58,7 +58,7 @@ pub async fn check(
.map_err(|_| Status::InternalServerError)?;
let id: Option<String> = if let Ok(check) = &check {
match save_query(&mut *db, &target, check, addr, checker.read().await).await {
match save_query(&mut db, &target, check, addr, checker.read().await).await {
Ok(id) => Some(id.to_string()),
Err(e) => {
warn!("api: failed to save check: {:?}", e);
@@ -70,7 +70,7 @@ pub async fn check(
};
let whitelist: Option<WhitelistedEntry> = if let Target::Domain(domain) = &target {
check_whitelist(domain, &mut *db)
check_whitelist(domain, &mut db)
.await
.map_err(|_| Status::InternalServerError)?
} else {
+23 -1
View File
@@ -544,7 +544,7 @@ mod tests {
config.hosts.push(Host {
id: "test".to_string(),
host: "192.0.2.1".to_string(),
host_type: HostType::Blacklist,
host_type: HostType::Whitelist,
file_path: String::new(),
timeout_sec: 1,
min_data: 1,
@@ -567,6 +567,28 @@ mod tests {
);
}
#[test]
fn reports_whitelist_when_blacklist_host_returns_data() {
let mut config = empty_config();
config.hosts.push(Host {
id: "test".to_string(),
host: "192.0.2.1".to_string(),
host_type: HostType::Blacklist,
file_path: String::new(),
timeout_sec: 1,
min_data: 1,
});
let results = vec![HostProbeResult {
host_id: "test".to_string(),
probe_evidence: ProbeEvidence::ClientHello,
}];
assert_eq!(
build_probe_verdicts(&results, &config, None, None, None, false),
vec!["whitelist"]
);
}
fn empty_config() -> ProbeConfig {
ProbeConfig {
version: String::new(),
+2 -4
View File
@@ -98,7 +98,7 @@ impl<'r> FromRequest<'r> for Agency {
let mut db = try_outcome!(
pool.acquire()
.await
.map_err(|e| Some(e))
.map_err(Some)
.or_forward(Status::InternalServerError)
);
let token = request.headers().get_one("Authorization");
@@ -114,7 +114,7 @@ impl<'r> FromRequest<'r> for Agency {
sqlx::query!("SELECT id, name FROM reporters WHERE token = $1", token)
.fetch_optional(&mut *db)
.await
.map_err(|e| Some(e))
.map_err(Some)
.or_forward(Status::InternalServerError)
);
agency
@@ -152,7 +152,6 @@ pub async fn check_whitelist(
)
.fetch_optional(db)
.await
.into()
}
#[derive(Debug, Serialize, sqlx::FromRow)]
@@ -190,5 +189,4 @@ ORDER BY b.bin;",
)
.fetch_all(db)
.await
.into()
}
+1 -1
View File
@@ -324,7 +324,7 @@ struct ParsedProbeConfig {
}
fn parse_probe_hosts(contents: &str) -> Result<ParsedProbeConfig, PublishError> {
let config: ProbeHostsFile = toml::from_str(&contents).map_err(PublishError::ConfigParse)?;
let config: ProbeHostsFile = toml::from_str(contents).map_err(PublishError::ConfigParse)?;
let hosts = config
.hosts
+4 -10
View File
@@ -39,20 +39,14 @@ pub async fn export_csv(
}
};
let mut db = pool
.acquire()
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let mut db = pool.acquire().await.map_err(io::Error::other)?;
let mut stream = db
.copy_out_raw(query)
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let mut stream = db.copy_out_raw(query).await.map_err(io::Error::other)?;
let mut data = Vec::default();
while let Some(bytes_result) = stream.next().await {
let bytes = bytes_result.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let bytes = bytes_result.map_err(io::Error::other)?;
data.extend(bytes)
}
@@ -75,7 +69,7 @@ pub async fn histogram(
.await
.map_err(|_| Status::InternalServerError)?;
Ok(Json(
collect_histogram(&mut *db, 50, limit, filter.is_some())
collect_histogram(&mut db, 50, limit, filter.is_some())
.await
.map_err(|_| Status::InternalServerError)?,
))