feat: ip probe traceroute

This commit is contained in:
Lowder
2026-08-17 00:45:31 +05:00
parent 38a1db3ecf
commit 1a06b8a0dc
20 changed files with 877 additions and 237 deletions
+13 -8
View File
@@ -21,28 +21,33 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- name: Install musl toolchain
run: sudo apt-get update && sudo apt-get install --yes musl-tools
- name: Install cargo-deb
run: cargo install cargo-deb
- name: Build release binary
run: cargo build --release --package probe --bin cheburprobe
run: cargo build --release --package probe --bin cheburprobe --target x86_64-unknown-linux-musl
- name: Build Debian package
run: cargo deb --package probe --no-build -- --bin cheburprobe
run: cargo deb --package probe --target x86_64-unknown-linux-musl --no-strip --no-build -- --bin cheburprobe
- name: Upload Linux amd64 binary
uses: actions/upload-artifact@v7
with:
name: cheburprobe-linux-amd64
path: target/release/cheburprobe
path: target/x86_64-unknown-linux-musl/release/cheburprobe
compression-level: 0
- name: Upload Linux amd64 Debian package
uses: actions/upload-artifact@v7
with:
name: cheburprobe-linux-amd64-deb
path: target/debian/*.deb
path: target/x86_64-unknown-linux-musl/debian/*.deb
compression-level: 0
linux-arm64:
@@ -61,25 +66,25 @@ jobs:
uses: houseabsolute/actions-rust-cross@v1
with:
command: build
target: "aarch64-unknown-linux-gnu"
target: "aarch64-unknown-linux-musl"
args: "--release --package probe --bin cheburprobe"
strip: true
- name: Build Debian package
run: cargo deb --package probe --target aarch64-unknown-linux-gnu --no-strip --no-build -- --bin cheburprobe
run: cargo deb --package probe --target aarch64-unknown-linux-musl --no-strip --no-build -- --bin cheburprobe
- name: Upload Linux arm64 binary
uses: actions/upload-artifact@v7
with:
name: cheburprobe-linux-arm64
path: target/aarch64-unknown-linux-gnu/release/cheburprobe
path: target/aarch64-unknown-linux-musl/release/cheburprobe
compression-level: 0
- name: Upload Linux arm64 Debian package
uses: actions/upload-artifact@v7
with:
name: cheburprobe-linux-arm64-deb
path: target/aarch64-unknown-linux-gnu/debian/*.deb
path: target/aarch64-unknown-linux-musl/debian/*.deb
compression-level: 0
windows-amd64:
Generated
+36 -2
View File
@@ -97,6 +97,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -806,6 +812,15 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "etherparse"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17304d06addb3283cdc4bd528e42dd95e73c8ee2d6492ffce415e93660885449"
dependencies = [
"arrayvec",
]
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -2531,6 +2546,20 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -2603,18 +2632,23 @@ dependencies = [
[[package]]
name = "probe"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"clap",
"env_logger",
"etherparse",
"futures",
"log",
"polling",
"rand 0.8.5",
"reports",
"rumqttc 0.25.1",
"rustix",
"rustls 0.23.35",
"serde",
"serde_json",
"socket2 0.6.3",
"tokio",
"tokio-rustls 0.26.4",
]
@@ -4671,7 +4705,7 @@ dependencies = [
[[package]]
name = "website"
version = "1.1.0"
version = "1.1.1"
dependencies = [
"dotenvy",
"env_logger",
+3 -2
View File
@@ -17,8 +17,9 @@ export type ProbeResult = {
region?: string | null;
provider?: string | null;
asn?: string | null;
verdict: "uncertain" | "sni_block" | "whitelist" | "ok";
host_results: ProbeHostResult[];
verdict: "uncertain" | "sni_block" | "tspu_block" | "whitelist" | "ok";
host_results: ProbeHostResult[] | null;
target_hop: number | null;
};
export type ProbeStatus = {
@@ -49,6 +49,13 @@ const verdictStyles = {
bg: "bg-red-500/10",
border: "border-red-500/20",
},
tspu_block: {
icon: CircleX,
text: "ТСПУ Блок",
class: "text-red-500",
bg: "bg-red-500/10",
border: "border-red-500/20",
},
whitelist: {
icon: ShieldCheck,
text: "Белый список",
@@ -84,10 +91,10 @@ const verdictStyles = {
<span
class={`w-2 h-2 rounded-full ${status.online_probes > 0 ? 'bg-green-500 animate-pulse' : 'bg-neutral-600'}`}
></span>
Сканеров онлайн: {status.online_probes}
Сканеров онлайн:{status.online_probes}
</div>
<div class="flex items-center gap-1">
Получено ответов: {probes.length} / {status.online_probes}
Получено ответов:{probes.length} /{status.online_probes}
</div>
</div>
</div>
@@ -123,13 +130,12 @@ const verdictStyles = {
</thead>
<tbody>
{#each probes as probe (probe.probe_id)}
{@const style = verdictStyles[(isStaticBlocked && probe.verdict === "ok") ? "cdn_block" : probe.verdict]}
{@const style = verdictStyles[(isStaticBlocked && probe.host_results?.length !== 0 && probe.verdict === "ok") ? "cdn_block" : probe.verdict]}
{@const isExpanded = !!expandedRows[probe.probe_id]}
<tr
class="border-b border-neutral-800/50 hover:bg-neutral-800/20 transition-colors cursor-pointer select-none"
onclick={() => toggleRow(probe.probe_id)}
onkeydown={(e) => e.key === 'Enter' && toggleRow(probe.probe_id)}
role="button"
tabindex="0"
>
<td class="p-3">
@@ -160,6 +166,11 @@ const verdictStyles = {
{#if isExpanded}
<tr class="bg-neutral-900/30">
<td colspan="4" class="p-4 border-b border-neutral-800/50">
<div class="text-md text-neutral-200 mb-2">
Блокировка на ТСПУ<b>
{probe.verdict === "tspu_block" ? ` обнаружена после ${probe.target_hop} прыжка` : " не обнаружена"}
</b>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#each probe.host_results as host}
<div
@@ -167,7 +178,7 @@ const verdictStyles = {
>
<div class="flex flex-col">
<span class="text-xs font-bold text-neutral-400">
Сервер {host.host_id}
Сервер{host.host_id}
({host.host === "Blacklist" ? "в заблокированных" : "в доступных"} диапазонах)
</span>
<span class="text-xs text-neutral-200">
@@ -176,7 +187,7 @@ const verdictStyles = {
{:else if host.probe_evidence.type === 'ClientHello'}
Блокировка после ClientHello
{:else if host.probe_evidence.type === 'DataTimeout'}
Таймаут получения данных, получено {host.probe_evidence.bytes} байт
Таймаут получения данных, получено{host.probe_evidence.bytes} байт
{:else if host.probe_evidence.type === 'ConnectionError'}
Ошибка подключения
{/if}
+1 -3
View File
@@ -31,9 +31,7 @@ const checkQuery = createQuery(() => ({
}));
const queryId = $derived(checkQuery.data?.id);
const shouldProbe = $derived(
!!queryId && checkQuery.data?.targetType === "Домен",
);
const shouldProbe = $derived(!!queryId);
function createInitialProbeData(id: string): ProbeQueryData {
return {
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
target: "http://localhost:8080",
},
},
},
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "probe"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license-file = "../LICENSE"
description = "Dynamic network probe daemon for Cheburcheck"
@@ -25,8 +25,11 @@ assets = [
anyhow = "1.0"
clap = { version = "4.6.0", features = ["derive", "env"] }
env_logger = "0.11.10"
etherparse = "0.21"
futures = "0.3"
log = { workspace = true }
polling = "3.11"
rustix = { version = "1.1", features = ["net"] }
rumqttc = { version = "0.25", features = ["use-rustls", "websocket"] }
serde = { workspace = true }
serde_json = "1.0"
@@ -34,3 +37,5 @@ tokio = { workspace = true }
reports = { path = "../reports" }
rustls = "0.23"
tokio-rustls = "0.26"
rand = "0.8"
socket2 = { version = "0.6", features = ["all"] }
+6 -1
View File
@@ -15,13 +15,18 @@ RUN --mount=type=cache,id=probe-target,target=/build/target \
FROM docker.io/debian:bookworm-slim
RUN groupadd --system app && \
RUN apt-get update && \
apt-get install --yes --no-install-recommends libcap2-bin && \
rm -rf /var/lib/apt/lists/* && \
groupadd --system app && \
useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=build /build/probe-bin ./probe
RUN setcap cap_net_raw=ep ./probe
USER app
ENTRYPOINT ["./probe"]
+5 -3
View File
@@ -104,6 +104,7 @@ cargo run --package probe --bin cheburprobe
```shell
docker run --rm \
--cap-add NET_RAW \
-e PROBE_ID=1 \
-e PROBE_TOKEN=ваш-токен \
-e MQTT_HOST=wss://cheburcheck.ru/mqtt \
@@ -121,9 +122,10 @@ docker run --rm \
| `--probe-id`, `PROBE_ID` | ID сканера. | обязательно |
| `--probe-token`, `PROBE_TOKEN` | Секретный токен сканера. | обязательно |
| `--max-concurrent-tasks`, `MAX_CONCURRENT_TASKS` | Максимальное количество одновременных заданий. | `8` |
| `--traceroute-max-hops`, `TRACEROUTE_MAX_HOPS` | Максимальный TTL для TCP traceroute. | `5` |
| `RUST_LOG` | Уровень логирования. | `info` |
`MAX_CONCURRENT_TASKS` должен быть больше нуля.
`MAX_CONCURRENT_TASKS` и `TRACEROUTE_MAX_HOPS` должны быть больше нуля. Для получения ICMP-ответов traceroute процессу требуется capability `CAP_NET_RAW`; systemd unit и Docker-образ настраивают её автоматически.
## Как работает проверка
@@ -131,8 +133,8 @@ docker run --rm \
1. публикует retained-статус `online` в MQTT;
2. подписывается на конфигурацию динамического сканирования;
3. получает задания на проверку доменов;
4. параллельно проверяет домен на настроенных тестовых хостах;
3. получает задания на проверку доменов и IP-адресов;
4. параллельно запускает SNI-проверки (для доменов), TCP traceroute до цели и контрольный TCP traceroute;
5. отправляет результат обратно в Cheburcheck.
Для каждого тестового хоста сканер открывает TCP-соединение, начинает TLS-handshake с проверяемым доменом в SNI, затем отправляет простой HTTP GET-запрос.
+2
View File
@@ -10,6 +10,8 @@ ExecStart=/usr/bin/cheburprobe
Restart=always
RestartSec=5s
DynamicUser=yes
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
LimitNOFILE=16384
[Install]
+85 -176
View File
@@ -1,24 +1,29 @@
mod sni;
mod traceroute;
use anyhow::{Context, Result, bail};
use clap::Parser;
use futures::future::join_all;
use log::{error, info, warn};
use reports::probe::{Host, HostProbeResult, ProbeConfig, ProbeEvidence, ProbeStatus, ProbeTask};
use rand::seq::SliceRandom;
use reports::probe::{ProbeConfig, ProbeResult, ProbeStatus, ProbeTask};
use rumqttc::{
AsyncClient, Event, Incoming, LastWill, MqttOptions, NetworkOptions, QoS, Transport,
};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme};
use std::collections::HashSet;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::RwLock;
use tokio::time;
use tokio_rustls::TlsConnector;
const CONFIG_TOPIC: &str = "probe/config/v1";
#[derive(Clone)]
struct LoadedProbeConfig {
config: ProbeConfig,
control_hosts_v4: Vec<Ipv4Addr>,
control_hosts_v6: Vec<Ipv6Addr>,
}
#[derive(Parser, Debug, Clone)]
#[command(author, version, about = "Dynamic probing daemon")]
struct Args {
@@ -39,6 +44,9 @@ struct Args {
#[arg(long, env = "MAX_CONCURRENT_TASKS", default_value_t = 8)]
max_concurrent_tasks: usize,
#[arg(long, env = "TRACEROUTE_MAX_HOPS", default_value_t = 5)]
traceroute_max_hops: u8,
}
#[tokio::main]
@@ -48,6 +56,9 @@ async fn main() -> Result<()> {
if args.max_concurrent_tasks == 0 {
bail!("max_concurrent_tasks must be greater than zero");
}
if args.traceroute_max_hops == 0 {
bail!("traceroute_max_hops must be greater than zero");
}
let status_topic = format!("probe/status/v1/{}", args.probe_id);
let offline_status = serde_json::to_vec(&ProbeStatus {
@@ -155,9 +166,35 @@ fn mqtt_transport(mqtt_host: &str) -> Result<Transport> {
}
}
async fn update_config(config: &Arc<RwLock<Option<ProbeConfig>>>, payload: &[u8]) -> Result<()> {
let value = serde_json::from_slice(payload).context("decode probe config")?;
*config.write().await = Some(value);
async fn update_config(
config: &Arc<RwLock<Option<LoadedProbeConfig>>>,
payload: &[u8],
) -> Result<()> {
let value: ProbeConfig = serde_json::from_slice(payload).context("decode probe config")?;
let mut control_hosts_v4 = HashSet::new();
let mut control_hosts_v6 = HashSet::new();
for domain in &value.control_hosts {
match tokio::net::lookup_host((domain.as_str(), 443)).await {
Ok(addresses) => {
for address in addresses {
match address.ip() {
IpAddr::V4(address) => {
control_hosts_v4.insert(address);
}
IpAddr::V6(address) => {
control_hosts_v6.insert(address);
}
}
}
}
Err(error) => warn!("failed to resolve control host {domain}: {error}"),
}
}
*config.write().await = Some(LoadedProbeConfig {
config: value,
control_hosts_v4: control_hosts_v4.into_iter().collect(),
control_hosts_v6: control_hosts_v6.into_iter().collect(),
});
info!("updated retained probe config");
Ok(())
}
@@ -201,7 +238,7 @@ async fn publish_status(
async fn handle_task(
client: &AsyncClient,
args: &Args,
config: &Arc<RwLock<Option<ProbeConfig>>>,
config: &Arc<RwLock<Option<LoadedProbeConfig>>>,
topic: &str,
task: ProbeTask<'_>,
received_at: Instant,
@@ -219,180 +256,52 @@ async fn handle_task(
return Ok(());
};
let config = config.read().await.clone();
let Some(config) = config else {
bail!("no config");
};
let result_topic = format!("probe/results/v1/{job_id}/{}", args.probe_id);
let probing = join_all(config.hosts.into_iter().map(|host| {
let target = task.target.to_string();
async move {
let probe_evidence = probe_host(&host, &target).await;
HostProbeResult {
probe_evidence,
host_id: host.id,
}
let config = config.read().await.clone();
let control_target = config.as_ref().and_then(|config| {
let mut rng = rand::thread_rng();
match task.ip {
IpAddr::V4(_) => config
.control_hosts_v4
.choose(&mut rng)
.copied()
.map(IpAddr::V4),
IpAddr::V6(_) => config
.control_hosts_v6
.choose(&mut rng)
.copied()
.map(IpAddr::V6),
}
}));
let result = match time::timeout(remaining, probing).await {
Ok(result) => result,
Err(_) => {
warn!(
"dropping expired task {job_id}: timeout {}ms",
task.timeout_ms
);
return Ok(());
});
let sni_check = sni::check_sni(
config.as_ref().map(|config| &config.config),
task.domain,
remaining,
job_id,
task.timeout_ms,
);
let target_traceroute = traceroute::tcp_traceroute(task.ip, args.traceroute_max_hops);
let control_traceroute = async {
match control_target {
Some(target) => traceroute::tcp_traceroute(target, args.traceroute_max_hops).await,
None => None,
}
};
let (responses, target_traceroute, control_traceroute) =
tokio::join!(sni_check, target_traceroute, control_traceroute);
let responses = responses?;
client
.publish(
result_topic,
QoS::AtLeastOnce,
false,
serde_json::to_vec(&result)?,
serde_json::to_vec(&ProbeResult {
responses,
target_traceroute,
control_traceroute,
})?,
)
.await
.context("publish probe result")
}
async fn probe_host(host: &Host, target: &str) -> ProbeEvidence {
let timeout = Duration::from_secs(host.timeout_sec as u64);
let tcp = match time::timeout(timeout, TcpStream::connect((host.host.as_str(), 443))).await {
Ok(Ok(tcp)) => tcp,
Ok(Err(_)) | Err(_) => return ProbeEvidence::ConnectionError,
};
let tls_config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(tls_config));
let server_name = match ServerName::try_from(target.to_string()) {
Ok(server_name) => server_name,
Err(_) => return ProbeEvidence::ClientHello,
};
let mut tls = match time::timeout(timeout, connector.connect(server_name, tcp)).await {
Ok(Ok(tls)) => tls,
Ok(Err(_)) | Err(_) => return ProbeEvidence::ClientHello,
};
let request = format!(
"GET /{} HTTP/1.1\r\nHost: {}\r\nUser-Agent: cheburcheck-probe/{}\r\nRange: bytes=0-{}\r\nConnection: close\r\n\r\n",
host.file_path.trim_start_matches('/'),
target,
env!("CARGO_PKG_VERSION"),
host.min_data.saturating_sub(1)
);
if !matches!(
time::timeout(timeout, tls.write_all(request.as_bytes())).await,
Ok(Ok(()))
) {
return ProbeEvidence::ClientHello;
}
let mut received = 0u32;
let mut headers_done = false;
let mut pending = Vec::new();
let mut buffer = [0u8; 8192];
loop {
match time::timeout(timeout, tls.read(&mut buffer)).await {
Ok(Ok(0)) | Err(_) => {
return if received >= host.min_data {
ProbeEvidence::Good
} else {
ProbeEvidence::DataTimeout { bytes: received }
};
}
Ok(Ok(bytes)) => {
add_response_body_bytes(
&buffer[..bytes],
&mut pending,
&mut headers_done,
&mut received,
);
if received >= host.min_data {
return ProbeEvidence::Good;
}
}
Ok(Err(_)) => {
return if received >= host.min_data {
ProbeEvidence::Good
} else {
ProbeEvidence::DataTimeout { bytes: received }
};
}
}
}
}
fn add_response_body_bytes(
chunk: &[u8],
pending: &mut Vec<u8>,
headers_done: &mut bool,
received: &mut u32,
) {
if *headers_done {
*received = received.saturating_add(chunk.len() as u32);
return;
}
pending.extend_from_slice(chunk);
if let Some(body_start) = pending.windows(4).position(|window| window == b"\r\n\r\n") {
*headers_done = true;
let body_bytes = pending.len().saturating_sub(body_start + 4);
*received = received.saturating_add(body_bytes as u32);
pending.clear();
}
}
#[derive(Debug)]
struct NoCertificateVerification;
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, TlsError> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
]
}
}
+184
View File
@@ -0,0 +1,184 @@
use anyhow::{Result, bail};
use futures::future::join_all;
use log::warn;
use reports::probe::{Host, HostProbeResult, ProbeConfig, ProbeEvidence};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time;
use tokio_rustls::TlsConnector;
pub async fn check_sni(
config: Option<&ProbeConfig>,
domain: Option<&str>,
timeout: Duration,
job_id: &str,
task_timeout_ms: u64,
) -> Result<Option<Vec<HostProbeResult>>> {
let Some(domain) = domain else {
return Ok(None);
};
let Some(config) = config else {
bail!("no config");
};
let probing = join_all(config.hosts.iter().map(|host| async move {
let probe_evidence = probe_host(host, domain).await;
HostProbeResult {
probe_evidence,
host_id: host.id.clone(),
}
}));
match time::timeout(timeout, probing).await {
Ok(responses) => Ok(Some(responses)),
Err(_) => {
warn!("SNI checks expired for task {job_id}: timeout {task_timeout_ms}ms");
Ok(None)
}
}
}
async fn probe_host(host: &Host, target: &str) -> ProbeEvidence {
let timeout = Duration::from_secs(host.timeout_sec as u64);
let tcp = match time::timeout(timeout, TcpStream::connect((host.host.as_str(), 443))).await {
Ok(Ok(tcp)) => tcp,
Ok(Err(_)) | Err(_) => return ProbeEvidence::ConnectionError,
};
let tls_config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(tls_config));
let server_name = match ServerName::try_from(target.to_string()) {
Ok(server_name) => server_name,
Err(_) => return ProbeEvidence::ClientHello,
};
let mut tls = match time::timeout(timeout, connector.connect(server_name, tcp)).await {
Ok(Ok(tls)) => tls,
Ok(Err(_)) | Err(_) => return ProbeEvidence::ClientHello,
};
let request = format!(
"GET /{} HTTP/1.1\r\nHost: {}\r\nUser-Agent: cheburcheck-probe/{}\r\nRange: bytes=0-{}\r\nConnection: close\r\n\r\n",
host.file_path.trim_start_matches('/'),
target,
env!("CARGO_PKG_VERSION"),
host.min_data.saturating_sub(1)
);
if !matches!(
time::timeout(timeout, tls.write_all(request.as_bytes())).await,
Ok(Ok(()))
) {
return ProbeEvidence::ClientHello;
}
let mut received = 0u32;
let mut headers_done = false;
let mut pending = Vec::new();
let mut buffer = [0u8; 8192];
loop {
match time::timeout(timeout, tls.read(&mut buffer)).await {
Ok(Ok(0)) | Err(_) => {
return if received >= host.min_data {
ProbeEvidence::Good
} else {
ProbeEvidence::DataTimeout { bytes: received }
};
}
Ok(Ok(bytes)) => {
add_response_body_bytes(
&buffer[..bytes],
&mut pending,
&mut headers_done,
&mut received,
);
if received >= host.min_data {
return ProbeEvidence::Good;
}
}
Ok(Err(_)) => {
return if received >= host.min_data {
ProbeEvidence::Good
} else {
ProbeEvidence::DataTimeout { bytes: received }
};
}
}
}
}
fn add_response_body_bytes(
chunk: &[u8],
pending: &mut Vec<u8>,
headers_done: &mut bool,
received: &mut u32,
) {
if *headers_done {
*received = received.saturating_add(chunk.len() as u32);
return;
}
pending.extend_from_slice(chunk);
if let Some(body_start) = pending.windows(4).position(|window| window == b"\r\n\r\n") {
*headers_done = true;
let body_bytes = pending.len().saturating_sub(body_start + 4);
*received = received.saturating_add(body_bytes as u32);
pending.clear();
}
}
#[derive(Debug)]
struct NoCertificateVerification;
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, TlsError> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
]
}
}
+286
View File
@@ -0,0 +1,286 @@
use etherparse::{
Icmpv4Type, Icmpv6Slice, Icmpv6Type, IpNumber, LaxNetSlice, LaxSlicedPacket, TransportSlice,
icmpv4, icmpv6,
};
use polling::{Event, Events, Poller};
use reports::probe::{TcpTracerouteOutcome, TcpTracerouteResult};
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
use std::io::{self, Read};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::{Duration, Instant};
const HTTPS_PORT: u16 = 443;
const HOP_TIMEOUT: Duration = Duration::from_secs(1);
pub async fn tcp_traceroute(target: IpAddr, max_hops: u8) -> Option<TcpTracerouteResult> {
tokio::task::spawn_blocking(move || trace_blocking(target, max_hops))
.await
.map_err(|error| log::warn!("TCP traceroute task failed for {target}: {error}"))
.ok()?
.map_err(|error| log::warn!("TCP traceroute failed for {target}: {error}"))
.ok()
}
fn trace_blocking(target: IpAddr, max_hops: u8) -> io::Result<TcpTracerouteResult> {
let (domain, icmp_protocol) = match target {
IpAddr::V4(_) => (Domain::IPV4, Protocol::ICMPV4),
IpAddr::V6(_) => (Domain::IPV6, Protocol::ICMPV6),
};
let receiver = Socket::new(domain, Type::RAW, Some(icmp_protocol))?;
let mut last_icmp_hop = None;
for ttl in 1..=max_hops {
let tcp = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?;
tcp.set_nonblocking(true)?;
match target {
IpAddr::V4(_) => tcp.set_ttl_v4(ttl as u32)?,
IpAddr::V6(_) => tcp.set_unicast_hops_v6(ttl as u32)?,
}
let unspecified = match target {
IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
};
tcp.bind(&SockAddr::from(unspecified))?;
let destination = SockAddr::from(SocketAddr::new(target, HTTPS_PORT));
if let Err(error) = tcp.connect(&destination) {
if error.kind() == io::ErrorKind::ConnectionRefused {
return Ok(TcpTracerouteResult {
target,
result: TcpTracerouteOutcome::Rst { hop: ttl },
});
}
if !is_connect_in_progress(&error) {
return Err(error);
}
}
let source_port = tcp
.local_addr()?
.as_socket()
.map(|addr| addr.port())
.unwrap_or(0);
match wait_for_hop_response(&receiver, &tcp, target, source_port)? {
HopResponse::IcmpTimeExceeded => last_icmp_hop = Some(ttl),
HopResponse::Rst => {
return Ok(TcpTracerouteResult {
target,
result: TcpTracerouteOutcome::Rst { hop: ttl },
});
}
HopResponse::Connected => {
return Ok(TcpTracerouteResult {
target,
result: TcpTracerouteOutcome::Connected { hop: ttl },
});
}
HopResponse::Timeout => {}
}
}
Ok(TcpTracerouteResult {
target,
result: last_icmp_hop
.map(|hop| TcpTracerouteOutcome::IcmpTimeExceeded { hop })
.unwrap_or(TcpTracerouteOutcome::Timeout),
})
}
fn is_connect_in_progress(error: &io::Error) -> bool {
error.kind() == io::ErrorKind::WouldBlock
|| error.raw_os_error() == Some(rustix::io::Errno::INPROGRESS.raw_os_error())
}
enum HopResponse {
IcmpTimeExceeded,
Rst,
Connected,
Timeout,
}
fn wait_for_hop_response(
receiver: &Socket,
tcp: &Socket,
target: IpAddr,
source_port: u16,
) -> io::Result<HopResponse> {
const ICMP_KEY: usize = 1;
const TCP_KEY: usize = 2;
let poller = Poller::new()?;
// SAFETY: both sockets remain alive and are removed from the poller before this function exits.
unsafe {
poller.add(receiver, Event::readable(ICMP_KEY))?;
if let Err(error) = poller.add(tcp, Event::writable(TCP_KEY)) {
poller.delete(receiver)?;
return Err(error);
}
}
let result = wait_on_poller(&poller, receiver, tcp, target, source_port);
let tcp_delete = poller.delete(tcp);
let receiver_delete = poller.delete(receiver);
let cleanup = tcp_delete.and(receiver_delete);
match result {
Ok(response) => cleanup.map(|()| response),
Err(error) => Err(error),
}
}
fn wait_on_poller(
poller: &Poller,
receiver: &Socket,
tcp: &Socket,
target: IpAddr,
source_port: u16,
) -> io::Result<HopResponse> {
const ICMP_KEY: usize = 1;
const TCP_KEY: usize = 2;
let deadline = Instant::now() + HOP_TIMEOUT;
let mut watch_tcp = true;
let mut buffer = [0u8; 2048];
let mut events = Events::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(HopResponse::Timeout);
}
events.clear();
match poller.wait(&mut events, Some(remaining)) {
Ok(0) => return Ok(HopResponse::Timeout),
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
if watch_tcp && events.iter().any(|event| event.key == TCP_KEY) {
match tcp.take_error()? {
Some(error) if error.kind() == io::ErrorKind::ConnectionRefused => {
return Ok(HopResponse::Rst);
}
None => return Ok(HopResponse::Connected),
Some(_) => watch_tcp = false,
}
}
if events.iter().any(|event| event.key == ICMP_KEY) {
let mut raw = receiver;
match raw.read(&mut buffer) {
Ok(bytes) if is_matching_time_exceeded(&buffer[..bytes], target, source_port) => {
return Ok(HopResponse::IcmpTimeExceeded);
}
Ok(_) => poller.modify(receiver, Event::readable(ICMP_KEY))?,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
poller.modify(receiver, Event::readable(ICMP_KEY))?;
}
Err(error) => return Err(error),
}
}
}
}
fn is_matching_time_exceeded(packet: &[u8], target: IpAddr, source_port: u16) -> bool {
match target {
IpAddr::V4(target) => matching_ipv4_time_exceeded(packet, target, source_port),
IpAddr::V6(target) => matching_ipv6_time_exceeded(packet, target, source_port),
}
}
fn matching_ipv4_time_exceeded(packet: &[u8], target: Ipv4Addr, source_port: u16) -> bool {
let Ok(outer) = LaxSlicedPacket::from_ip(packet) else {
return false;
};
let Some(TransportSlice::Icmpv4(icmp)) = outer.transport else {
return false;
};
matches!(
icmp.icmp_type(),
Icmpv4Type::TimeExceeded(icmpv4::TimeExceededCode::TtlExceededInTransit)
) && matching_quoted_tcp(icmp.payload(), IpAddr::V4(target), source_port)
}
fn matching_ipv6_time_exceeded(packet: &[u8], target: Ipv6Addr, source_port: u16) -> bool {
let icmp = if packet.first().map(|byte| byte >> 4) == Some(6) {
let Ok(outer) = LaxSlicedPacket::from_ip(packet) else {
return false;
};
let Some(TransportSlice::Icmpv6(icmp)) = outer.transport else {
return false;
};
icmp
} else {
let Ok(icmp) = Icmpv6Slice::from_slice(packet) else {
return false;
};
icmp
};
matches!(
icmp.icmp_type(),
Icmpv6Type::TimeExceeded(icmpv6::TimeExceededCode::HopLimitExceeded)
) && matching_quoted_tcp(icmp.payload(), IpAddr::V6(target), source_port)
}
fn matching_quoted_tcp(packet: &[u8], target: IpAddr, source_port: u16) -> bool {
let Ok(quoted) = LaxSlicedPacket::from_ip(packet) else {
return false;
};
let (ip_number, payload) = match (quoted.net, target) {
(Some(LaxNetSlice::Ipv4(ip)), IpAddr::V4(target))
if ip.header().destination_addr() == target =>
{
(ip.payload().ip_number, ip.payload().payload)
}
(Some(LaxNetSlice::Ipv6(ip)), IpAddr::V6(target))
if ip.header().destination_addr() == target =>
{
(ip.payload().ip_number, ip.payload().payload)
}
_ => return false,
};
if ip_number != IpNumber::TCP {
return false;
}
let Some(ports) = payload.get(..4) else {
return false;
};
u16::from_be_bytes([ports[0], ports[1]]) == source_port
&& u16::from_be_bytes([ports[2], ports[3]]) == HTTPS_PORT
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_ipv4_time_exceeded_for_tcp_flow() {
let target = Ipv4Addr::new(203, 0, 113, 10);
let mut packet = vec![0; 20 + 8 + 20 + 8];
packet[0] = 0x45;
packet[2..4].copy_from_slice(&56u16.to_be_bytes());
packet[9] = IpNumber::ICMP.0;
packet[20] = 11;
packet[28] = 0x45;
packet[30..32].copy_from_slice(&28u16.to_be_bytes());
packet[37] = 6;
packet[44..48].copy_from_slice(&target.octets());
packet[48..50].copy_from_slice(&42_000u16.to_be_bytes());
packet[50..52].copy_from_slice(&HTTPS_PORT.to_be_bytes());
assert!(matching_ipv4_time_exceeded(&packet, target, 42_000));
assert!(!matching_ipv4_time_exceeded(&packet, target, 42_001));
}
#[test]
fn matches_ipv6_time_exceeded_for_tcp_flow() {
let target = "2001:db8::10".parse::<Ipv6Addr>().unwrap();
let mut packet = vec![0; 8 + 40 + 8];
packet[0] = 3;
packet[8] = 0x60;
packet[14] = 6;
packet[32..48].copy_from_slice(&target.octets());
packet[48..50].copy_from_slice(&42_000u16.to_be_bytes());
packet[50..52].copy_from_slice(&HTTPS_PORT.to_be_bytes());
assert!(matching_ipv6_time_exceeded(&packet, target, 42_000));
assert!(!matching_ipv6_time_exceeded(&packet, target, 42_001));
}
}
+29 -1
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
#[derive(Clone, Serialize, Deserialize)]
pub struct ProbeStatus<'a> {
@@ -13,6 +14,8 @@ pub struct ProbeConfig {
pub task_timeout_ms: u64,
pub published_at: String,
pub hosts: Vec<Host>,
#[serde(default)]
pub control_hosts: Vec<String>,
}
#[derive(Clone, Serialize, Deserialize)]
@@ -35,7 +38,8 @@ pub enum HostType {
pub struct ProbeTask<'a> {
pub id: String,
pub query_id: String,
pub target: &'a str,
pub domain: Option<&'a str>,
pub ip: IpAddr,
pub created_at: String,
pub timeout_ms: u64,
}
@@ -45,6 +49,30 @@ pub struct ProbeResultEvent {
pub job_id: String,
pub probe_id: String,
pub host_results: Vec<HostProbeResult>,
pub target_traceroute: Option<TcpTracerouteResult>,
pub control_traceroute: Option<TcpTracerouteResult>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ProbeResult {
pub responses: Option<Vec<HostProbeResult>>,
pub target_traceroute: Option<TcpTracerouteResult>,
pub control_traceroute: Option<TcpTracerouteResult>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct TcpTracerouteResult {
pub target: IpAddr,
pub result: TcpTracerouteOutcome,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum TcpTracerouteOutcome {
Rst { hop: u8 },
Connected { hop: u8 },
IcmpTimeExceeded { hop: u8 },
Timeout,
}
#[derive(Clone, Serialize, Deserialize)]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "website"
version = "1.1.0"
version = "1.1.1"
edition = "2024"
[dependencies]
@@ -0,0 +1,5 @@
ALTER TABLE probe_reports
ADD COLUMN target_hop_count SMALLINT,
ADD COLUMN target_trace_result VARCHAR(32),
ADD COLUMN control_hop_count SMALLINT,
ADD COLUMN control_trace_result VARCHAR(32);
+1
View File
@@ -1,5 +1,6 @@
timeout_sec = 3
min_data = 65536
control_hosts = ["kinopoisk.ru", "wildberries.ru", "yandex.ru", "mail.ru"]
[[hosts]]
id = "hil-hetzner" # hil-speed.hetzner.com
+162 -16
View File
@@ -4,6 +4,7 @@ use log::warn;
use querying::target::Target;
use reports::probe::{
Host, HostProbeResult, HostType, ProbeConfig, ProbeEvidence, ProbeResultEvent,
TcpTracerouteOutcome, TcpTracerouteResult,
};
use rocket::State;
use rocket::http::Status;
@@ -15,6 +16,7 @@ use rocket_client_addr::ClientRealAddr;
use sqlx::postgres::PgPool;
use sqlx::types::Uuid;
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::sync::Arc;
#[derive(sqlx::FromRow)]
@@ -37,23 +39,33 @@ pub async fn probe_query(
}
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: Option<(String, Vec<String>)> =
sqlx::query_as("SELECT query, resolved_ips 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 (query, resolved_ips) = query.ok_or(Status::NotFound)?;
let target = Target::from(query.trim());
let domain = match &target {
Target::Domain(domain) => Some(domain.as_str()),
Target::Ipv4(_) | Target::Ipv6(_) => None,
Target::Ipv4Subnet(_) | Target::Ipv6Subnet(_) | Target::Asn(_) => {
return Err(Status::BadRequest);
}
};
let ip = resolved_ips
.first()
.and_then(|ip| ip.parse::<IpAddr>().ok())
.ok_or(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)
mqtt.publish_probe_task(id, domain, ip)
.await
.map_err(|error| {
warn!("api: failed to publish probe task for {id}: {error}");
@@ -73,7 +85,7 @@ pub async fn probe_query(
yield Event::data(json!({
"id": id,
"target": domain,
"target": query,
"online_probes": online_probes,
}).to_string()).event("started");
@@ -88,6 +100,8 @@ pub async fn probe_query(
match result {
Ok(result) => {
responded_probes.insert(result.probe_id.clone());
let target_traceroute = result.target_traceroute.clone();
let control_traceroute = result.control_traceroute.clone();
let reporter_info = match fetch_probe_reporter_info(&result.probe_id, &pool).await {
Ok(info) => info,
Err(error) => {
@@ -99,7 +113,13 @@ pub async fn probe_query(
}
};
let response = build_probe_response(result, &probe_config, reporter_info);
if let Err(error) = insert_probe_report(query_id, &response, &pool).await {
if let Err(error) = insert_probe_report(
query_id,
&response,
target_traceroute.as_ref(),
control_traceroute.as_ref(),
&pool,
).await {
warn!("api: failed to save probe report for query {id}: {error}");
}
yield Event::data(response.to_string()).event("result");
@@ -127,7 +147,21 @@ pub fn build_probe_response(
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 verdict = build_probe_verdict(
&raw.host_results,
config,
raw.target_traceroute.as_ref(),
raw.control_traceroute.as_ref(),
);
let target_hop =
raw.target_traceroute
.as_ref()
.and_then(|traceroute| match &traceroute.result {
TcpTracerouteOutcome::Rst { hop }
| TcpTracerouteOutcome::Connected { hop }
| TcpTracerouteOutcome::IcmpTimeExceeded { hop } => Some(*hop),
TcpTracerouteOutcome::Timeout => None,
});
let region = reporter_info.as_ref().and_then(|info| info.region.as_ref());
let provider = reporter_info
.as_ref()
@@ -155,12 +189,15 @@ pub fn build_probe_response(
"asn": asn,
"verdict": verdict,
"host_results": host_results,
"target_hop": target_hop,
})
}
async fn insert_probe_report(
query_id: Uuid,
response: &Value,
target_traceroute: Option<&TcpTracerouteResult>,
control_traceroute: Option<&TcpTracerouteResult>,
pool: &PgPool,
) -> Result<(), sqlx::Error> {
let probe_id = response
@@ -171,6 +208,8 @@ async fn insert_probe_report(
.get("verdict")
.and_then(Value::as_str)
.unwrap_or("uncertain");
let (target_hop_count, target_trace_result) = traceroute_columns(target_traceroute);
let (control_hop_count, control_trace_result) = traceroute_columns(control_traceroute);
let Some(probe_id) = probe_id else {
warn!("api: ignoring probe report with non-numeric probe_id");
@@ -179,25 +218,57 @@ async fn insert_probe_report(
sqlx::query(
r#"
INSERT INTO probe_reports (query_id, probe_id, verdict, result)
VALUES ($1, $2, $3, $4)
INSERT INTO probe_reports (
query_id,
probe_id,
verdict,
result,
target_hop_count,
target_trace_result,
control_hop_count,
control_trace_result
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (query_id, probe_id)
DO UPDATE SET
date = NOW(),
verdict = EXCLUDED.verdict,
result = EXCLUDED.result
result = EXCLUDED.result,
target_hop_count = EXCLUDED.target_hop_count,
target_trace_result = EXCLUDED.target_trace_result,
control_hop_count = EXCLUDED.control_hop_count,
control_trace_result = EXCLUDED.control_trace_result
"#,
)
.bind(query_id)
.bind(probe_id)
.bind(verdict)
.bind(response)
.bind(target_hop_count)
.bind(target_trace_result)
.bind(control_hop_count)
.bind(control_trace_result)
.execute(pool)
.await?;
Ok(())
}
fn traceroute_columns(
traceroute: Option<&TcpTracerouteResult>,
) -> (Option<i16>, Option<&'static str>) {
let Some(traceroute) = traceroute else {
return (None, None);
};
let (hop, result) = match &traceroute.result {
TcpTracerouteOutcome::Rst { hop } => (Some(*hop), "Rst"),
TcpTracerouteOutcome::Connected { hop } => (Some(*hop), "Connected"),
TcpTracerouteOutcome::IcmpTimeExceeded { hop } => (Some(*hop), "IcmpTimeExceeded"),
TcpTracerouteOutcome::Timeout => (None, "Timeout"),
};
(hop.map(i16::from), Some(result))
}
async fn fetch_probe_reporter_info(
probe_id: &str,
pool: &PgPool,
@@ -210,7 +281,31 @@ async fn fetch_probe_reporter_info(
.await
}
fn build_probe_verdict(results: &[HostProbeResult], config: &ProbeConfig) -> &'static str {
fn build_probe_verdict(
results: &[HostProbeResult],
config: &ProbeConfig,
target_traceroute: Option<&TcpTracerouteResult>,
control_traceroute: Option<&TcpTracerouteResult>,
) -> &'static str {
if let (
Some(TcpTracerouteResult {
result: TcpTracerouteOutcome::IcmpTimeExceeded { hop: target_hop },
..
}),
Some(TcpTracerouteResult {
result: TcpTracerouteOutcome::IcmpTimeExceeded { hop: control_hop },
..
}),
) = (target_traceroute, control_traceroute)
&& target_hop < control_hop
{
return "tspu_block";
}
if results.is_empty() {
return "ok";
}
let matched = results
.iter()
.filter_map(|result| {
@@ -306,3 +401,54 @@ fn done_event(id: &str, response_count: usize, online_probes: usize) -> Event {
fn is_strict_majority(total: usize, count: usize) -> bool {
count > total / 2
}
#[cfg(test)]
mod tests {
use super::*;
fn icmp_trace(hop: u8) -> TcpTracerouteResult {
TcpTracerouteResult {
target: "192.0.2.1".parse().unwrap(),
result: TcpTracerouteOutcome::IcmpTimeExceeded { hop },
}
}
#[test]
fn tspu_block_requires_an_earlier_target_icmp_hop() {
let target = icmp_trace(3);
let control = icmp_trace(5);
assert_eq!(
build_probe_verdict(&[], &empty_config(), Some(&target), Some(&control)),
"tspu_block"
);
let target = icmp_trace(5);
assert_eq!(
build_probe_verdict(&[], &empty_config(), Some(&target), Some(&control)),
"uncertain"
);
}
#[test]
fn tspu_block_requires_two_icmp_outcomes() {
let target = TcpTracerouteResult {
target: "192.0.2.1".parse().unwrap(),
result: TcpTracerouteOutcome::Connected { hop: 2 },
};
let control = icmp_trace(5);
assert_eq!(
build_probe_verdict(&[], &empty_config(), Some(&target), Some(&control)),
"uncertain"
);
}
fn empty_config() -> ProbeConfig {
ProbeConfig {
version: String::new(),
task_timeout_ms: 0,
published_at: String::new(),
hosts: vec![],
control_hosts: vec![],
}
}
}
+29 -10
View File
@@ -1,6 +1,6 @@
use log::{info, warn};
use reports::probe::HostType;
use reports::probe::{Host, ProbeConfig, ProbeResultEvent, ProbeStatus, ProbeTask};
use reports::probe::{Host, ProbeConfig, ProbeResult, ProbeResultEvent, ProbeStatus, ProbeTask};
use rocket::serde::json::serde_json;
use rumqttc::{AsyncClient, Event as MqttEvent, Incoming, MqttOptions, QoS};
use serde::Deserialize;
@@ -8,6 +8,7 @@ use sqlx::types::Uuid;
use sqlx::types::chrono::Utc;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
@@ -60,6 +61,8 @@ pub type ProbeResultReceiver = rocket::tokio::sync::broadcast::Receiver<ProbeRes
struct ProbeHostsFile {
timeout_sec: u32,
min_data: u32,
#[serde(default)]
control_hosts: Vec<String>,
hosts: Vec<ProbeHostEntry>,
}
@@ -85,6 +88,7 @@ impl MqttPublisher {
task_timeout_ms,
published_at: Utc::now().to_rfc3339(),
hosts: Vec::new(),
control_hosts: Vec::new(),
}
}));
let admin_token = match std::env::var("MQTT_ADMIN_TOKEN") {
@@ -216,14 +220,16 @@ impl MqttPublisher {
pub async fn publish_probe_task(
&self,
query_id: Uuid,
domain: &str,
domain: Option<&str>,
ip: IpAddr,
) -> Result<(), PublishError> {
let client = self.client.as_ref().ok_or(PublishError::NotConfigured)?;
let query_id = query_id.to_string();
let task = ProbeTask {
id: query_id.clone(),
query_id: query_id.clone(),
target: domain,
domain,
ip,
created_at: Utc::now().to_rfc3339(),
timeout_ms: self.task_timeout_ms,
};
@@ -250,7 +256,7 @@ async fn publish_probe_config(
}
fn load_probe_config(task_timeout_ms: u64) -> Result<ProbeConfig, PublishError> {
let hosts = if let Some(path) = std::env::var_os("PROBE_CONFIG_PATH") {
let config = if let Some(path) = std::env::var_os("PROBE_CONFIG_PATH") {
let contents = std::fs::read_to_string(path).map_err(PublishError::Config)?;
parse_probe_hosts(&contents)?
} else {
@@ -260,14 +266,20 @@ fn load_probe_config(task_timeout_ms: u64) -> Result<ProbeConfig, PublishError>
version: env!("CARGO_PKG_VERSION").to_string(),
task_timeout_ms,
published_at: Utc::now().to_rfc3339(),
hosts,
hosts: config.hosts,
control_hosts: config.control_hosts,
})
}
fn parse_probe_hosts(contents: &str) -> Result<Vec<Host>, PublishError> {
struct ParsedProbeConfig {
hosts: Vec<Host>,
control_hosts: Vec<String>,
}
fn parse_probe_hosts(contents: &str) -> Result<ParsedProbeConfig, PublishError> {
let config: ProbeHostsFile = toml::from_str(&contents).map_err(PublishError::ConfigParse)?;
Ok(config
let hosts = config
.hosts
.into_iter()
.map(|host| Host {
@@ -278,7 +290,12 @@ fn parse_probe_hosts(contents: &str) -> Result<Vec<Host>, PublishError> {
timeout_sec: host.timeout_sec.unwrap_or(config.timeout_sec),
min_data: host.min_data.unwrap_or(config.min_data),
})
.collect())
.collect();
Ok(ParsedProbeConfig {
hosts,
control_hosts: config.control_hosts,
})
}
async fn dispatch_probe_status(
@@ -320,7 +337,7 @@ async fn dispatch_probe_result(
return;
};
let result = match serde_json::from_slice(payload) {
let result: ProbeResult = match serde_json::from_slice(payload) {
Ok(result) => result,
Err(error) => {
warn!("ignoring invalid probe result JSON on {topic}: {error}");
@@ -333,7 +350,9 @@ async fn dispatch_probe_result(
let _ = sender.send(ProbeResultEvent {
job_id: job_id.to_string(),
probe_id: probe_id.to_string(),
host_results: result,
host_results: result.responses.unwrap_or_default(),
target_traceroute: result.target_traceroute,
control_traceroute: result.control_traceroute,
});
}
}
+5 -6
View File
@@ -56,7 +56,7 @@ pub async fn auth(
pool: &rocket::State<PgPool>,
) -> Json<MqttAuthResponse> {
let request = request.into_inner();
let _ = (request.clientid, request.protocol);
let _ = request.protocol;
if request.username == "admin"
&& std::env::var("MQTT_ADMIN_TOKEN")
@@ -66,20 +66,19 @@ pub async fn auth(
return Json(MqttAuthResponse::allow_superuser());
}
if request.username != "probe" || request.password.is_empty() {
if request.username != "probe" || request.clientid.is_empty() || request.password.is_empty() {
return Json(MqttAuthResponse::deny());
}
let token_exists =
let reporter_id =
sqlx::query_scalar::<_, i32>("SELECT id FROM reporters WHERE token = $1 LIMIT 1")
.bind(request.password)
.fetch_optional(&**pool)
.await
.ok()
.flatten()
.is_some();
.flatten();
if token_exists {
if reporter_id.is_some_and(|id| id.to_string() == request.clientid) {
Json(MqttAuthResponse::allow())
} else {
Json(MqttAuthResponse::deny())