feat: add recent query logging and display in UI
CI / Windows x64 (push) Successful in 9m12s

This commit is contained in:
2026-08-09 16:25:09 +07:00
parent 0223dd817b
commit 2b8e0cb701
6 changed files with 305 additions and 5 deletions
+139
View File
@@ -89,6 +89,10 @@ const REPORT_INTERVAL: Duration = Duration::from_secs(30);
const FAILOVER_AFTER: u64 = 5;
/// Сколько доменов помнить для панели.
const STATS_CAP: usize = 256;
/// Сколько последних запросов держать для панели. Двести — это примерно
/// полминуты жизни машины под нагрузкой и несколько часов простоя, то есть
/// ровно то окно, в котором ещё имеет смысл спрашивать «а что сейчас было».
const RECENT_CAP: usize = 200;
/// Потолок одновременных TCP-запросов. По TCP ходят единицы клиентов, но
/// принимать их без счёта — способ остаться без дескрипторов.
const MAX_TCP: usize = 32;
@@ -419,6 +423,112 @@ impl Attempts {
}
}
/* ── лента последних запросов ──────────────────────────────── */
/// Что резолвер сделал с запросом.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
/// Отсечён по списку, наверх не пошёл.
Blocked,
/// Ответ взят из своего кэша.
Cached,
/// Ушёл вышестоящему серверу.
Upstream,
}
/// Одна запись ленты.
///
/// Имя лежит прямо в структуре массивом, а не отдельной строкой в куче. Кольцо
/// заводится один раз при запуске, и запись в него не трогает распределитель
/// памяти — иначе на каждый запрос в системе приходилось бы выделение и
/// освобождение, а горячий путь резолвера построен ровно на том, чтобы их не
/// было. Ценой идут постоянные 56 КБ, что на фоне кэша ответов незаметно.
struct Seen {
name: [u8; MAX_NAME],
len: u8,
qtype: u16,
verdict: Verdict,
at: Instant,
}
#[derive(Serialize)]
pub struct RecentQuery {
pub name: String,
pub kind: String,
pub verdict: Verdict,
pub ago_secs: u64,
}
/// Кольцо: доходит до предела и начинает затирать самое старое.
///
/// Живёт только в памяти и умирает вместе со службой. На диск не попадает
/// намеренно — это перечень всего, что человек открывал, и хранить такое
/// дольше, чем нужно для ответа на вопрос «что сейчас произошло», незачем.
#[derive(Default)]
struct Recent {
items: Vec<Seen>,
/// Куда писать следующую запись; она же — самая старая, когда кольцо полно.
next: usize,
}
impl Recent {
fn record(&mut self, name: &str, qtype: u16, verdict: Verdict, at: Instant) {
if self.items.len() < RECENT_CAP {
self.items.push(Seen {
name: [0; MAX_NAME],
len: 0,
qtype: 0,
verdict: Verdict::Upstream,
at,
});
}
// Пишем на месте, а не собираем запись целиком и не двигаем её: имя
// копируется ровно на свою длину, а не на все 255 байт массива.
let slot = &mut self.items[self.next];
let len = name.len().min(MAX_NAME);
slot.name[..len].copy_from_slice(&name.as_bytes()[..len]);
slot.len = len as u8;
slot.qtype = qtype;
slot.verdict = verdict;
slot.at = at;
self.next = (self.next + 1) % RECENT_CAP;
}
/// Отдаёт ленту от свежего к старому — в том порядке, в каком её читают.
fn snapshot(&self, now: Instant) -> Vec<RecentQuery> {
let n = self.items.len();
(0..n)
.map(|k| {
let slot = &self.items[(self.next + n - 1 - k) % n];
RecentQuery {
name: String::from_utf8_lossy(&slot.name[..slot.len as usize]).into_owned(),
kind: type_name(slot.qtype),
verdict: slot.verdict,
ago_secs: now.saturating_duration_since(slot.at).as_secs(),
}
})
.collect()
}
}
/// Название типа записи для панели. Считается только на запрос из панели, на
/// горячем пути хранится сырое число.
fn type_name(qtype: u16) -> String {
match qtype {
TYPE_A => "A".to_string(),
TYPE_AAAA => "AAAA".to_string(),
5 => "CNAME".to_string(),
12 => "PTR".to_string(),
15 => "MX".to_string(),
16 => "TXT".to_string(),
33 => "SRV".to_string(),
65 => "HTTPS".to_string(),
other => format!("#{other}"),
}
}
/// Счётчики для панели. Обновляются на горячем пути, поэтому атомарные:
/// блокировка ради статистики — худшая из возможных причин ждать.
#[derive(Default)]
@@ -441,6 +551,7 @@ pub struct DnsStatus {
pub timeouts: u64,
pub error: Option<String>,
pub attempts: Vec<BlockedDomain>,
pub recent: Vec<RecentQuery>,
}
/* ── общее состояние ───────────────────────────────────────── */
@@ -450,6 +561,7 @@ pub struct Dns {
blocklist: watch::Sender<Arc<Blocklist>>,
counters: Arc<Counters>,
attempts: Arc<Mutex<Attempts>>,
recent: Arc<Mutex<Recent>>,
/// Строка с адресом вышестоящего сервера либо причина, почему резолвер не
/// работает. Панель должна показывать это раньше, чем родитель заметит, что
/// блокировка молчит.
@@ -463,6 +575,7 @@ impl Dns {
blocklist,
counters: Arc::default(),
attempts: Arc::default(),
recent: Arc::default(),
state: Mutex::new((false, String::new(), None)),
}
}
@@ -486,6 +599,11 @@ impl Dns {
if let Ok(mut s) = self.state.lock() {
*s = (false, String::new(), None);
}
// Лента уходит вместе с резолвером: выключенная блокировка не должна
// оставлять после себя перечень того, что человек открывал.
if let Ok(mut recent) = self.recent.lock() {
*recent = Recent::default();
}
}
pub fn status(&self) -> DnsStatus {
@@ -512,6 +630,12 @@ impl Dns {
.map(|s| s.clone())
.unwrap_or((false, String::new(), None));
let recent = self
.recent
.lock()
.map(|r| r.snapshot(now))
.unwrap_or_default();
DnsStatus {
listening,
upstream,
@@ -521,6 +645,7 @@ impl Dns {
timeouts: self.counters.timeouts.load(Ordering::Relaxed),
error,
attempts,
recent,
}
}
}
@@ -550,6 +675,7 @@ struct Resolver {
blocklist: watch::Receiver<Arc<Blocklist>>,
counters: Arc<Counters>,
attempts: Arc<Mutex<Attempts>>,
recent: Arc<Mutex<Recent>>,
}
impl Resolver {
@@ -557,6 +683,14 @@ impl Resolver {
self.upstreams[self.active.load(Ordering::Relaxed) % self.upstreams.len()]
}
/// Заносит запрос в ленту последних. Кольцо заведено заранее, поэтому
/// вызов не выделяет памяти и держит мьютекс считаные наносекунды.
fn note(&self, name: &str, qtype: u16, verdict: Verdict, now: Instant) {
if let Ok(mut recent) = self.recent.lock() {
recent.record(name, qtype, verdict, now);
}
}
/// Решает судьбу запроса. Возвращает готовый ответ, если отвечаем сами.
///
/// Здесь не должно появиться ни одного `await`: пока задача разбирает
@@ -579,6 +713,7 @@ impl Resolver {
attempts.record(name, now);
}
self.counters.blocked.fetch_add(1, Ordering::Relaxed);
self.note(name, q.qtype, Verdict::Blocked, now);
return match write_blocked(pkt, &q) {
Some(n) => Decision::Answer(n),
None => Decision::Drop,
@@ -606,9 +741,12 @@ impl Resolver {
if let Some(n) = cache.get(key, packet_id(pkt), pkt, now) {
pkt[HEADER..q.end].copy_from_slice(&asked[..asked_len]);
self.counters.cache_hits.fetch_add(1, Ordering::Relaxed);
drop(cache); // мьютекс кэша не держим на время записи в ленту
self.note(name, q.qtype, Verdict::Cached, now);
return Decision::Answer(n);
}
}
self.note(name, q.qtype, Verdict::Upstream, now);
Decision::Forward(Some(key.into()))
}
}
@@ -965,6 +1103,7 @@ pub async fn run(state: Shared, upstreams: Vec<SocketAddr>, shutdown: watch::Rec
blocklist: state.dns.blocklist.subscribe(),
counters: Arc::clone(&state.dns.counters),
attempts: Arc::clone(&state.dns.attempts),
recent: Arc::clone(&state.dns.recent),
upstreams,
});