Skip to main content

sidecheck_core/
sampler.rs

1//! Сбор сырых измерений времени отклика.
2//!
3//! Ключевое требование методологии: классы запросов (например, "верный
4//! префикс" / "неверный префикс") должны чередоваться в случайном порядке,
5//! а не идти блоками "сначала все A, потом все B" — иначе на результат
6//! повлияет прогрев сервера, фоновая нагрузка или дрейф сети во времени,
7//! а не сама утечка. См. dudect / Crosby-Wallach методологию.
8
9use anyhow::{Context, Result};
10use rand::seq::SliceRandom;
11use rand::Rng;
12use std::time::Instant;
13
14/// Куда подставляется тестовое значение. Header — самый частый случай для
15/// API-ключей, Query — для legacy-эндпоинтов с токеном в URL, JsonBody —
16/// для типичных JSON-логинов (POST /login {"password": "..."}).
17#[derive(Clone, Debug)]
18pub enum InjectionPoint {
19    Header(String),
20    Query(String),
21    /// Имя поля в JSON-объекте тела запроса, плюс опциональный шаблон
22    /// остальных полей (например {"username": "admin"}), которые бэкенду
23    /// нужны, чтобы вообще дойти до сравнения секрета. Если шаблон не
24    /// задан — тело как раньше состоит из одного этого поля.
25    JsonBody {
26        field: String,
27        template: Option<serde_json::Map<String, serde_json::Value>>,
28    },
29}
30
31impl InjectionPoint {
32    pub fn describe(&self) -> String {
33        match self {
34            InjectionPoint::Header(n) => format!("header {n}"),
35            InjectionPoint::Query(n) => format!("query param {n}"),
36            InjectionPoint::JsonBody { field, template } => match template {
37                Some(_) => format!("JSON field {field} (with body template)"),
38                None => format!("JSON field {field}"),
39            },
40        }
41    }
42}
43
44/// HTTP-цель: URL и точка, в которую подставляется тестовое значение.
45pub struct HttpTarget {
46    client: reqwest::blocking::Client,
47    url: String,
48    injection: InjectionPoint,
49}
50
51impl HttpTarget {
52    pub fn new(url: impl Into<String>, injection: InjectionPoint) -> Result<Self> {
53        Self::new_with_options(url, injection, false)
54    }
55
56    pub fn new_with_options(
57        url: impl Into<String>,
58        injection: InjectionPoint,
59        accept_invalid_certs: bool,
60    ) -> Result<Self> {
61        let client = reqwest::blocking::Client::builder()
62            .timeout(std::time::Duration::from_secs(10))
63            // важно: не подтягивать TCP keep-alive пул иначе первый запрос
64            // в каждом классе будет медленнее из-за установки соединения
65            .pool_max_idle_per_host(4)
66            .danger_accept_invalid_certs(accept_invalid_certs)
67            .build()
68            .context("failed to build HTTP client")?;
69        Ok(Self {
70            client,
71            url: url.into(),
72            injection,
73        })
74    }
75
76    /// Один замер: отправляет запрос с заданным значением в настроенной
77    /// точке инъекции, возвращает время до получения полного ответа в секундах.
78    pub fn measure(&self, value: &str) -> Result<f64> {
79        let start = Instant::now();
80
81        let resp = match &self.injection {
82            InjectionPoint::Header(name) => self
83                .client
84                .get(&self.url)
85                .header(name.as_str(), value)
86                .send(),
87            InjectionPoint::Query(name) => self
88                .client
89                .get(&self.url)
90                .query(&[(name.as_str(), value)])
91                .send(),
92            InjectionPoint::JsonBody { field, template } => {
93                let mut body = template.clone().unwrap_or_default();
94                body.insert(field.clone(), serde_json::Value::String(value.to_string()));
95                self.client
96                    .post(&self.url)
97                    .json(&serde_json::Value::Object(body))
98                    .send()
99            }
100        }
101        .context("request failed")?;
102
103        // важно дочитать тело — иначе замер не включает полное время ответа
104        let _ = resp.bytes().context("failed to read response body")?;
105        Ok(start.elapsed().as_secs_f64())
106    }
107}
108
109/// Генерирует заведомо неверное значение той же длины, что и реальный
110/// секрет — чтобы длина payload не была отдельной переменной, искажающей
111/// измерение (см. предупреждение в CLI про разную длину value_a/value_b).
112/// Принимает внешний RNG, чтобы весь прогон был воспроизводим по одному seed.
113pub fn random_wrong_value(secret: &str, rng: &mut impl Rng) -> String {
114    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
115    loop {
116        let candidate: String = (0..secret.len())
117            .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
118            .collect();
119        if candidate != secret {
120            return candidate;
121        }
122    }
123}
124
125#[derive(Debug, Default)]
126pub struct RawSamples {
127    pub class_a: Vec<f64>,
128    pub class_b: Vec<f64>,
129    /// число запросов, которые не удалось выполнить (таймаут, разрыв
130    /// соединения и т.п.) — не считаются в статистике, но если их много,
131    /// результату нельзя доверять
132    pub failures: usize,
133}
134
135/// Прогоняет n_per_class измерений на каждый класс, чередуя их случайными
136/// блоками, чтобы усреднить влияние дрейфа во времени. Одиночные сбои сети
137/// не прерывают весь прогон — считаются и репортятся отдельно, но если
138/// доля сбоев превышает max_failure_ratio, прогон останавливается: на таком
139/// нестабильном канале доверять таймингам нельзя.
140pub fn run_interleaved(
141    target: &HttpTarget,
142    value_a: &str,
143    value_b: &str,
144    n_per_class: usize,
145    block_size: usize,
146    rng: &mut impl Rng,
147    mut on_progress: impl FnMut(usize, usize),
148) -> Result<RawSamples> {
149    const MAX_FAILURE_RATIO: f64 = 0.1;
150
151    let mut result = RawSamples::default();
152    let mut remaining_a = n_per_class;
153    let mut remaining_b = n_per_class;
154    let total = n_per_class * 2;
155    let mut done = 0;
156
157    while remaining_a > 0 || remaining_b > 0 {
158        let mut block: Vec<bool> = Vec::new(); // true = class A
159        block.extend(std::iter::repeat_n(true, block_size.min(remaining_a)));
160        block.extend(std::iter::repeat_n(false, block_size.min(remaining_b)));
161        block.shuffle(rng);
162
163        for is_a in block {
164            let measurement = if is_a {
165                remaining_a -= 1;
166                target.measure(value_a)
167            } else {
168                remaining_b -= 1;
169                target.measure(value_b)
170            };
171
172            match measurement {
173                Ok(elapsed) => {
174                    if is_a {
175                        result.class_a.push(elapsed);
176                    } else {
177                        result.class_b.push(elapsed);
178                    }
179                }
180                Err(_) => {
181                    result.failures += 1;
182                }
183            }
184
185            done += 1;
186            on_progress(done, total);
187
188            let attempted = result.class_a.len() + result.class_b.len() + result.failures;
189            if attempted > 100 && (result.failures as f64 / attempted as f64) > MAX_FAILURE_RATIO {
190                anyhow::bail!(
191                    "aborting: {} of {} requests failed ({}%). the target or network is too \
192                     unstable for a reliable measurement — fix connectivity first.",
193                    result.failures,
194                    attempted,
195                    (result.failures as f64 / attempted as f64 * 100.0) as u32
196                );
197            }
198        }
199    }
200
201    Ok(result)
202}
203
204/// Результат простого сбора замеров для `sidecheck doctor` — без деления
205/// на классы, нас интересует только форма распределения RTT до цели.
206#[derive(Debug, Default)]
207pub struct PlainSamples {
208    pub latencies: Vec<f64>,
209    pub failures: usize,
210}
211
212/// Собирает n последовательных замеров одного и того же запроса. В отличие
213/// от run_interleaved — не прерывается на потерях пакетов, а считает их: для
214/// doctor-режима сам процент потерь и есть часть диагноза, а не повод остановиться.
215pub fn collect_plain(
216    target: &HttpTarget,
217    value: &str,
218    n: usize,
219    mut on_progress: impl FnMut(usize, usize),
220) -> PlainSamples {
221    let mut result = PlainSamples::default();
222    for i in 0..n {
223        match target.measure(value) {
224            Ok(elapsed) => result.latencies.push(elapsed),
225            Err(_) => result.failures += 1,
226        }
227        on_progress(i + 1, n);
228    }
229    result
230}