|
+
+ Блокировка на ТСПУ
+ {probe.verdict === "tspu_block" ? ` обнаружена после ${probe.target_hop} прыжка` : " не обнаружена"}
+
+
{#each probe.host_results as host}
- Сервер {host.host_id}
+ Сервер{host.host_id}
({host.host === "Blacklist" ? "в заблокированных" : "в доступных"} диапазонах)
@@ -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}
diff --git a/frontend/src/routes/check/+page.svelte b/frontend/src/routes/check/+page.svelte
index 3a837ed..94c0b73 100644
--- a/frontend/src/routes/check/+page.svelte
+++ b/frontend/src/routes/check/+page.svelte
@@ -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 {
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 6b0cbbd..c160615 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -6,7 +6,7 @@ export default defineConfig({
server: {
proxy: {
"/api": {
- target: "http://localhost:8000",
+ target: "http://localhost:8080",
},
},
},
diff --git a/probe/Cargo.toml b/probe/Cargo.toml
index 5dc8fec..b1e8e3b 100644
--- a/probe/Cargo.toml
+++ b/probe/Cargo.toml
@@ -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"] }
diff --git a/probe/Dockerfile b/probe/Dockerfile
index 53486ce..0fabcb6 100644
--- a/probe/Dockerfile
+++ b/probe/Dockerfile
@@ -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"]
diff --git a/probe/README.md b/probe/README.md
index b83665a..745416d 100644
--- a/probe/README.md
+++ b/probe/README.md
@@ -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-запрос.
diff --git a/probe/debian/cheburprobe.service b/probe/debian/cheburprobe.service
index 75738b1..c7efbc6 100644
--- a/probe/debian/cheburprobe.service
+++ b/probe/debian/cheburprobe.service
@@ -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]
diff --git a/probe/src/main.rs b/probe/src/main.rs
index 1eab789..4afee48 100644
--- a/probe/src/main.rs
+++ b/probe/src/main.rs
@@ -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,
+ control_hosts_v6: Vec,
+}
+
#[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 {
}
}
-async fn update_config(config: &Arc>>, 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>>,
+ 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>>,
+ config: &Arc>>,
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,
- 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 {
- Ok(ServerCertVerified::assertion())
- }
-
- fn verify_tls12_signature(
- &self,
- _message: &[u8],
- _cert: &CertificateDer<'_>,
- _dss: &DigitallySignedStruct,
- ) -> Result {
- Ok(HandshakeSignatureValid::assertion())
- }
-
- fn verify_tls13_signature(
- &self,
- _message: &[u8],
- _cert: &CertificateDer<'_>,
- _dss: &DigitallySignedStruct,
- ) -> Result {
- Ok(HandshakeSignatureValid::assertion())
- }
-
- fn supported_verify_schemes(&self) -> Vec {
- 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,
- ]
- }
-}
diff --git a/probe/src/sni.rs b/probe/src/sni.rs
new file mode 100644
index 0000000..da8204a
--- /dev/null
+++ b/probe/src/sni.rs
@@ -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 |