mirror of
https://github.com/LowderPlay/cheburcheck.git
synced 2026-09-29 18:28:00 +03:00
309 lines
9.4 KiB
Rust
309 lines
9.4 KiB
Rust
use super::rate_limit::ApiRateLimiter;
|
|
use crate::mqtt::{MqttPublisher, PublishError};
|
|
use log::warn;
|
|
use querying::target::Target;
|
|
use reports::probe::{
|
|
Host, HostProbeResult, HostType, ProbeConfig, ProbeEvidence, ProbeResultEvent,
|
|
};
|
|
use rocket::State;
|
|
use rocket::http::Status;
|
|
use rocket::response::stream::{Event, EventStream};
|
|
use rocket::serde::json::serde_json::Value;
|
|
use rocket::serde::json::serde_json::json;
|
|
use rocket::tokio::time;
|
|
use rocket_client_addr::ClientRealAddr;
|
|
use sqlx::postgres::PgPool;
|
|
use sqlx::types::Uuid;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
pub struct ProbeReporterInfo {
|
|
pub region: Option<String>,
|
|
pub provider: Option<String>,
|
|
pub asn: Option<String>,
|
|
}
|
|
|
|
#[get("/probe/<id>")]
|
|
pub async fn probe_query(
|
|
id: &str,
|
|
addr: &ClientRealAddr,
|
|
pool: &State<PgPool>,
|
|
mqtt: &State<MqttPublisher>,
|
|
limiter: &State<Arc<ApiRateLimiter>>,
|
|
) -> Result<EventStream![Event], Status> {
|
|
if limiter.check_key(&addr.ip).is_err() {
|
|
return Err(Status::TooManyRequests);
|
|
}
|
|
|
|
let id = Uuid::try_parse(id).map_err(|_| Status::BadRequest)?;
|
|
let query: Option<String> = sqlx::query_scalar("SELECT query FROM queries WHERE id = $1")
|
|
.bind(id)
|
|
.fetch_optional(&**pool)
|
|
.await
|
|
.map_err(|_| Status::InternalServerError)?;
|
|
|
|
let query = query.ok_or(Status::NotFound)?;
|
|
let Target::Domain(domain) = Target::from(query.trim()) else {
|
|
return Err(Status::BadRequest);
|
|
};
|
|
|
|
let mut results = mqtt.subscribe_probe_results(id).await.map_err(|error| {
|
|
warn!("api: failed to subscribe to probe results for {id}: {error}");
|
|
publish_error_status(error)
|
|
})?;
|
|
|
|
mqtt.publish_probe_task(id, &domain)
|
|
.await
|
|
.map_err(|error| {
|
|
warn!("api: failed to publish probe task for {id}: {error}");
|
|
publish_error_status(error)
|
|
})?;
|
|
|
|
let timeout = mqtt.task_timeout();
|
|
let online_probes = mqtt.online_probe_count().await;
|
|
let probe_config = mqtt.probe_config();
|
|
let pool = pool.inner().clone();
|
|
let query_id = id;
|
|
let id = id.to_string();
|
|
Ok(EventStream! {
|
|
let mut responded_probes = HashSet::new();
|
|
let timeout = time::sleep(timeout);
|
|
rocket::tokio::pin!(timeout);
|
|
|
|
yield Event::data(json!({
|
|
"id": id,
|
|
"target": domain,
|
|
"online_probes": online_probes,
|
|
}).to_string()).event("started");
|
|
|
|
loop {
|
|
if responded_probes.len() >= online_probes {
|
|
yield done_event(&id, responded_probes.len(), online_probes);
|
|
break;
|
|
}
|
|
|
|
rocket::tokio::select! {
|
|
result = results.recv() => {
|
|
match result {
|
|
Ok(result) => {
|
|
responded_probes.insert(result.probe_id.clone());
|
|
let reporter_info = match fetch_probe_reporter_info(&result.probe_id, &pool).await {
|
|
Ok(info) => info,
|
|
Err(error) => {
|
|
warn!(
|
|
"api: failed to fetch reporter info for probe {}: {}",
|
|
result.probe_id, error
|
|
);
|
|
None
|
|
}
|
|
};
|
|
let response = build_probe_response(result, &probe_config, reporter_info);
|
|
if let Err(error) = insert_probe_report(query_id, &response, &pool).await {
|
|
warn!("api: failed to save probe report for query {id}: {error}");
|
|
}
|
|
yield Event::data(response.to_string()).event("result");
|
|
}
|
|
Err(rocket::tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
|
continue;
|
|
}
|
|
Err(rocket::tokio::sync::broadcast::error::RecvError::Closed) => {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
_ = &mut timeout => {
|
|
yield done_event(&id, responded_probes.len(), online_probes);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
pub fn build_probe_response(
|
|
raw: ProbeResultEvent,
|
|
config: &ProbeConfig,
|
|
reporter_info: Option<ProbeReporterInfo>,
|
|
) -> Value {
|
|
let hosts: HashMap<&String, &Host> = config.hosts.iter().map(|h| (&h.id, h)).collect();
|
|
let verdict = build_probe_verdict(&raw.host_results, config);
|
|
let region = reporter_info.as_ref().and_then(|info| info.region.as_ref());
|
|
let provider = reporter_info
|
|
.as_ref()
|
|
.and_then(|info| info.provider.as_ref());
|
|
let asn = reporter_info.as_ref().and_then(|info| info.asn.as_ref());
|
|
let host_results = raw
|
|
.host_results
|
|
.into_iter()
|
|
.filter_map(|result| {
|
|
let host = hosts.get(&result.host_id)?;
|
|
|
|
Some(json!({
|
|
"host_id": result.host_id,
|
|
"host": host.host_type,
|
|
"probe_evidence": result.probe_evidence,
|
|
}))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
json!({
|
|
"job_id": raw.job_id,
|
|
"probe_id": raw.probe_id,
|
|
"region": region,
|
|
"provider": provider,
|
|
"asn": asn,
|
|
"verdict": verdict,
|
|
"host_results": host_results,
|
|
})
|
|
}
|
|
|
|
async fn insert_probe_report(
|
|
query_id: Uuid,
|
|
response: &Value,
|
|
pool: &PgPool,
|
|
) -> Result<(), sqlx::Error> {
|
|
let probe_id = response
|
|
.get("probe_id")
|
|
.and_then(Value::as_str)
|
|
.and_then(|probe_id| probe_id.parse::<i32>().ok());
|
|
let verdict = response
|
|
.get("verdict")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("uncertain");
|
|
|
|
let Some(probe_id) = probe_id else {
|
|
warn!("api: ignoring probe report with non-numeric probe_id");
|
|
return Ok(());
|
|
};
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO probe_reports (query_id, probe_id, verdict, result)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (query_id, probe_id)
|
|
DO UPDATE SET
|
|
date = NOW(),
|
|
verdict = EXCLUDED.verdict,
|
|
result = EXCLUDED.result
|
|
"#,
|
|
)
|
|
.bind(query_id)
|
|
.bind(probe_id)
|
|
.bind(verdict)
|
|
.bind(response)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn fetch_probe_reporter_info(
|
|
probe_id: &str,
|
|
pool: &PgPool,
|
|
) -> Result<Option<ProbeReporterInfo>, sqlx::Error> {
|
|
sqlx::query_as::<_, ProbeReporterInfo>(
|
|
"SELECT region, provider, asn FROM reporters WHERE id = $1 LIMIT 1",
|
|
)
|
|
.bind(probe_id.parse::<i32>().unwrap_or(-1))
|
|
.fetch_optional(pool)
|
|
.await
|
|
}
|
|
|
|
fn build_probe_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'static str {
|
|
let matched = results
|
|
.iter()
|
|
.filter_map(|result| {
|
|
config
|
|
.hosts
|
|
.iter()
|
|
.find(|host| host.id == result.host_id)
|
|
.map(|host| (host, &result.probe_evidence))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
if matched.is_empty() {
|
|
return "uncertain";
|
|
}
|
|
|
|
if is_strict_majority(
|
|
matched.len(),
|
|
matched
|
|
.iter()
|
|
.filter(|(_, evidence)| matches!(evidence, ProbeEvidence::ClientHello))
|
|
.count(),
|
|
) {
|
|
return "sni_block";
|
|
}
|
|
|
|
if is_strict_majority(
|
|
matched.len(),
|
|
matched
|
|
.iter()
|
|
.filter(|(_, evidence)| matches!(evidence, ProbeEvidence::Good))
|
|
.count(),
|
|
) {
|
|
return "whitelist";
|
|
}
|
|
|
|
let blacklist = matched
|
|
.iter()
|
|
.filter(|(host, _)| matches!(host.host_type, HostType::Blacklist))
|
|
.collect::<Vec<_>>();
|
|
let whitelist = matched
|
|
.iter()
|
|
.filter(|(host, _)| matches!(host.host_type, HostType::Whitelist))
|
|
.collect::<Vec<_>>();
|
|
|
|
let most_blacklist_timed_out = !blacklist.is_empty()
|
|
&& is_strict_majority(
|
|
blacklist.len(),
|
|
blacklist
|
|
.iter()
|
|
.filter(|(_, evidence)| matches!(evidence, ProbeEvidence::DataTimeout { .. }))
|
|
.count(),
|
|
);
|
|
let most_whitelist_good = !whitelist.is_empty()
|
|
&& is_strict_majority(
|
|
whitelist.len(),
|
|
whitelist
|
|
.iter()
|
|
.filter(|(_, evidence)| matches!(evidence, ProbeEvidence::Good))
|
|
.count(),
|
|
);
|
|
|
|
if most_blacklist_timed_out && most_whitelist_good {
|
|
"ok"
|
|
} else {
|
|
"uncertain"
|
|
}
|
|
}
|
|
|
|
fn publish_error_status(error: PublishError) -> Status {
|
|
match error {
|
|
PublishError::NotConfigured => Status::ServiceUnavailable,
|
|
PublishError::Config(_)
|
|
| PublishError::ConfigParse(_)
|
|
| PublishError::Serialize(_)
|
|
| PublishError::Subscribe(_)
|
|
| PublishError::Publish(_) => Status::InternalServerError,
|
|
}
|
|
}
|
|
|
|
fn done_event(id: &str, response_count: usize, online_probes: usize) -> Event {
|
|
Event::data(
|
|
json!({
|
|
"id": id,
|
|
"status": "done",
|
|
"response_count": response_count,
|
|
"online_probes": online_probes,
|
|
})
|
|
.to_string(),
|
|
)
|
|
.event("done")
|
|
}
|
|
|
|
fn is_strict_majority(total: usize, count: usize) -> bool {
|
|
count > total / 2
|
|
}
|