diff --git a/Cargo.lock b/Cargo.lock
index 3f9053d..822a77b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4509,7 +4509,7 @@ dependencies = [
[[package]]
name = "website"
-version = "1.3.7"
+version = "1.3.8"
dependencies = [
"anyhow",
"dotenvy",
diff --git a/frontend/src/lib/api/probe.ts b/frontend/src/lib/api/probe.ts
index b4f74b7..a8390a0 100644
--- a/frontend/src/lib/api/probe.ts
+++ b/frontend/src/lib/api/probe.ts
@@ -44,6 +44,7 @@ export type ProbeResult = {
provider?: string | null;
asn?: string | null;
verdicts: ProbeVerdict[];
+ cdn_unblocked?: boolean;
host_results: ProbeHostResult[] | null;
target_hop: number | null;
dpi_hop: number | null;
@@ -61,7 +62,10 @@ export function displayProbeVerdicts(
isStaticCdn: boolean,
): DisplayProbeVerdict[] {
return probe.verdicts.map((verdict) =>
- isStaticCdn && probe.host_results?.length !== 0 && verdict === "ok"
+ isStaticCdn &&
+ !probe.cdn_unblocked &&
+ probe.host_results?.length !== 0 &&
+ verdict === "ok"
? "cdn_block"
: verdict,
);
diff --git a/frontend/src/routes/kb/probing/+page.svelte b/frontend/src/routes/kb/probing/+page.svelte
index ad89c72..c0d67cb 100644
--- a/frontend/src/routes/kb/probing/+page.svelte
+++ b/frontend/src/routes/kb/probing/+page.svelte
@@ -126,8 +126,9 @@ import KbNote from "$lib/components/kb/KbNote.svelte";
Доступен
- – сайт не найден в списках CDN, не блокируется на контрольных хостах, но
- может блокироваться на зарубежных CDN.
+ – для домена блокировка на контрольных хостах не выявлена, но возможна
+ блокировка на зарубежных CDN. Для IP-адреса TCP-трассировка получила ответ
+ после DPI-узла, поэтому блокировка на ТСПУ не выявлена.
CDN Блок (16-20)
diff --git a/website/Cargo.toml b/website/Cargo.toml
index ec7f0b6..e345916 100644
--- a/website/Cargo.toml
+++ b/website/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "website"
-version = "1.3.7"
+version = "1.3.8"
edition = "2024"
[dependencies]
diff --git a/website/migrations/20260923000000_reporter_cdn_unblocked.sql b/website/migrations/20260923000000_reporter_cdn_unblocked.sql
new file mode 100644
index 0000000..9cb41ae
--- /dev/null
+++ b/website/migrations/20260923000000_reporter_cdn_unblocked.sql
@@ -0,0 +1,2 @@
+ALTER TABLE reporters
+ ADD COLUMN IF NOT EXISTS cdn_unblocked BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/website/src/api/probe.rs b/website/src/api/probe.rs
index 4981e89..dff1109 100644
--- a/website/src/api/probe.rs
+++ b/website/src/api/probe.rs
@@ -25,6 +25,7 @@ pub struct ProbeReporterInfo {
pub provider: Option,
pub asn: Option,
pub disable_traceroutes: bool,
+ pub cdn_unblocked: bool,
}
#[get("/probe/?")]
@@ -226,6 +227,9 @@ pub fn build_probe_response(
raw.dpi_hop,
raw.dns.as_ref(),
is_ip_target,
+ reporter_info
+ .as_ref()
+ .is_some_and(|info| info.cdn_unblocked),
);
let target_hop =
raw.target_traceroute
@@ -261,6 +265,7 @@ pub fn build_probe_response(
"region": region,
"provider": provider,
"asn": asn,
+ "cdn_unblocked": reporter_info.as_ref().is_some_and(|info| info.cdn_unblocked),
"verdicts": verdicts,
"host_results": host_results,
"target_hop": target_hop,
@@ -348,7 +353,7 @@ async fn fetch_probe_reporter_info(
pool: &PgPool,
) -> Result, sqlx::Error> {
sqlx::query_as::<_, ProbeReporterInfo>(
- "SELECT region, provider, asn, disable_traceroutes FROM reporters WHERE id = $1 LIMIT 1",
+ "SELECT region, provider, asn, disable_traceroutes, cdn_unblocked FROM reporters WHERE id = $1 LIMIT 1",
)
.bind(probe_id.parse::().unwrap_or(-1))
.fetch_optional(pool)
@@ -362,6 +367,7 @@ fn build_probe_verdicts(
dpi_hop: Option,
dns: Option<&reports::probe::DnsProbeResult>,
is_ip_target: bool,
+ cdn_unblocked: bool,
) -> Vec<&'static str> {
if results.is_empty() && target_traceroute.is_none() && dns.is_none() {
return vec!["uncertain"];
@@ -371,17 +377,19 @@ fn build_probe_verdicts(
&& target_traceroute
.is_some_and(|traceroute| matches!(&traceroute.result, TcpTracerouteOutcome::Timeout));
- // IP tasks do not run the SNI/CDN host checks. A reachable IP therefore does not prove that
- // a statically listed CDN address is unblocked.
if is_ip_target {
return if tspu_block {
vec!["tspu_block"]
+ } else if target_traceroute
+ .is_some_and(|traceroute| !matches!(&traceroute.result, TcpTracerouteOutcome::Timeout))
+ {
+ vec!["ok"]
} else {
vec!["uncertain"]
};
}
- let host_verdict = build_host_verdict(results, config);
+ let host_verdict = build_host_verdict(results, config, cdn_unblocked);
let dns_spoofing = dns.is_some_and(|result| result.spoofing_detected);
let mut verdicts = [
@@ -400,7 +408,11 @@ fn build_probe_verdicts(
verdicts
}
-fn build_host_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'static str {
+fn build_host_verdict(
+ results: &[HostProbeResult],
+ config: &ProbeConfig,
+ cdn_unblocked: bool,
+) -> &'static str {
let matched = results
.iter()
.filter_map(|result| {
@@ -430,19 +442,21 @@ fn build_host_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'st
.count(),
) {
"sni_block"
- } else if is_strict_majority(
- matched
- .iter()
- .filter(|(h, _)| matches!(h.host_type, HostType::Blacklist))
- .count(),
- matched
- .iter()
- .filter(|(host, evidence)| {
- matches!(host.host_type, HostType::Blacklist)
- && !matches!(evidence, ProbeEvidence::DataTimeout { .. })
- })
- .count(),
- ) {
+ } else if !cdn_unblocked
+ && is_strict_majority(
+ matched
+ .iter()
+ .filter(|(h, _)| matches!(h.host_type, HostType::Blacklist))
+ .count(),
+ matched
+ .iter()
+ .filter(|(host, evidence)| {
+ matches!(host.host_type, HostType::Blacklist)
+ && !matches!(evidence, ProbeEvidence::DataTimeout { .. })
+ })
+ .count(),
+ )
+ {
"whitelist"
} else {
let blacklist = matched
@@ -454,12 +468,18 @@ fn build_host_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'st
.filter(|(host, _)| matches!(host.host_type, HostType::Whitelist))
.collect::>();
- let most_blacklist_timed_out = !blacklist.is_empty()
+ let most_blacklist_matches_baseline = !blacklist.is_empty()
&& is_strict_majority(
blacklist.len(),
blacklist
.iter()
- .filter(|(_, evidence)| matches!(evidence, ProbeEvidence::DataTimeout { .. }))
+ .filter(|(_, evidence)| {
+ if cdn_unblocked {
+ matches!(evidence, ProbeEvidence::Good)
+ } else {
+ matches!(evidence, ProbeEvidence::DataTimeout { .. })
+ }
+ })
.count(),
);
let most_whitelist_good = !whitelist.is_empty()
@@ -471,7 +491,7 @@ fn build_host_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'st
.count(),
);
- if most_blacklist_timed_out && most_whitelist_good {
+ if most_blacklist_matches_baseline && most_whitelist_good {
"ok"
} else {
"uncertain"
@@ -562,7 +582,7 @@ mod tests {
#[test]
fn no_checks_returns_uncertain() {
assert_eq!(
- build_probe_verdicts(&[], &empty_config(), None, None, None, false),
+ build_probe_verdicts(&[], &empty_config(), None, None, None, false, false),
vec!["uncertain"]
);
}
@@ -574,17 +594,33 @@ mod tests {
result: TcpTracerouteOutcome::Timeout,
};
assert_eq!(
- build_probe_verdicts(&[], &empty_config(), Some(&timeout), Some(4), None, true),
+ build_probe_verdicts(
+ &[],
+ &empty_config(),
+ Some(&timeout),
+ Some(4),
+ None,
+ true,
+ false
+ ),
vec!["tspu_block"]
);
assert_eq!(
- build_probe_verdicts(&[], &empty_config(), Some(&timeout), None, None, true),
+ build_probe_verdicts(
+ &[],
+ &empty_config(),
+ Some(&timeout),
+ None,
+ None,
+ true,
+ false
+ ),
vec!["uncertain"]
);
}
#[test]
- fn reachable_ip_target_is_uncertain() {
+ fn reachable_ip_target_is_ok() {
for result in [
TcpTracerouteOutcome::IcmpTimeExceeded { hop: 5 },
TcpTracerouteOutcome::Connected { hop: 5 },
@@ -595,8 +631,16 @@ mod tests {
result,
};
assert_eq!(
- build_probe_verdicts(&[], &empty_config(), Some(&target), Some(4), None, true),
- vec!["uncertain"]
+ build_probe_verdicts(
+ &[],
+ &empty_config(),
+ Some(&target),
+ Some(4),
+ None,
+ true,
+ false
+ ),
+ vec!["ok"]
);
}
}
@@ -625,7 +669,7 @@ mod tests {
};
assert_eq!(
- build_probe_verdicts(&results, &config, None, None, Some(&dns), false),
+ build_probe_verdicts(&results, &config, None, None, Some(&dns), false, false),
vec!["sni_block", "dns_spoofing"]
);
}
@@ -647,11 +691,89 @@ mod tests {
}];
assert_eq!(
- build_probe_verdicts(&results, &config, None, None, None, false),
+ build_probe_verdicts(&results, &config, None, None, None, false, false),
vec!["whitelist"]
);
}
+ #[test]
+ fn accessible_cdn_is_not_a_whitelist_exception_for_unblocked_reporter() {
+ let mut config = empty_config();
+ for (id, host_type) in [
+ ("foreign-cdn", HostType::Blacklist),
+ ("control", HostType::Whitelist),
+ ] {
+ config.hosts.push(Host {
+ id: id.to_string(),
+ host: "192.0.2.1".to_string(),
+ host_type,
+ file_path: String::new(),
+ timeout_sec: 1,
+ min_data: 1,
+ });
+ }
+ let results = ["foreign-cdn", "control"]
+ .into_iter()
+ .map(|host_id| HostProbeResult {
+ host_id: host_id.to_string(),
+ probe_evidence: ProbeEvidence::Good,
+ })
+ .collect::>();
+
+ assert_eq!(
+ build_probe_verdicts(&results, &config, None, None, None, false, false),
+ vec!["whitelist"]
+ );
+ assert_eq!(
+ build_probe_verdicts(&results, &config, None, None, None, false, true),
+ vec!["ok"]
+ );
+
+ let response = build_probe_response(
+ ProbeResultEvent {
+ job_id: "job".to_string(),
+ probe_id: "42".to_string(),
+ host_results: results,
+ target_traceroute: None,
+ dpi_hop: None,
+ dns: None,
+ },
+ &config,
+ Some(ProbeReporterInfo {
+ region: None,
+ provider: None,
+ asn: None,
+ disable_traceroutes: false,
+ cdn_unblocked: true,
+ }),
+ false,
+ );
+ assert_eq!(response["cdn_unblocked"], json!(true));
+ assert_eq!(response["verdicts"], json!(["ok"]));
+ }
+
+ #[test]
+ fn unblocked_reporter_still_detects_sni_block() {
+ let mut config = empty_config();
+ config.hosts.push(Host {
+ id: "control".to_string(),
+ host: "192.0.2.1".to_string(),
+ host_type: HostType::Whitelist,
+ file_path: String::new(),
+ timeout_sec: 1,
+ min_data: 1,
+ });
+ let results = vec![HostProbeResult {
+ host_id: "control".to_string(),
+ probe_evidence: ProbeEvidence::ClientHello,
+ }];
+
+ assert_eq!(
+ build_probe_verdicts(&results, &config, None, None, None, false, true),
+ vec!["sni_block"]
+ );
+ }
+
fn empty_config() -> ProbeConfig {
ProbeConfig {
version: String::new(),