From 2b8e0cb701dd2037403beb5f7f3bdedf3f6be819 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 9 Aug 2026 16:25:09 +0700 Subject: [PATCH] feat: add recent query logging and display in UI --- CHANGELOG.md | 19 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- src/dns.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++++ src/dns_tests.rs | 87 ++++++++++++++++++++++++++++- static/app.js | 61 ++++++++++++++++++++- 6 files changed, 305 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b819393..b7b073a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/). +## [0.4.0] — 2026-08-09 + +"You can now see what the resolver is actually being asked." + +### Added + +- A live view of the last 200 queries the resolver handled, each marked as + blocked, served from cache, or sent upstream, with a filter by name. + +### Notes + +- The view is held in memory only. It is never written to the log file and is + discarded when blocking is switched off, because it records every name looked + up on the machine and not only the blocked ones — it exists to answer "what + just happened", not to accumulate a browsing history. +- It costs a fixed 56 KB of memory and roughly 8 ns per query, so it is on by + default with nothing to configure. + ## [0.3.0] — 2026-08-09 "It now closes websites, not just programs." @@ -91,6 +109,7 @@ browser." - Runs as a Windows SYSTEM service and requires 64-bit Windows. - Configuration is stored in `C:\ProgramData\SysHelper`. +[0.4.0]: https://git.robonen.ru/robonen/syshelper/compare/v0.3.0...v0.4.0 [0.3.0]: https://git.robonen.ru/robonen/syshelper/compare/v0.2.0...v0.3.0 [0.2.0]: https://git.robonen.ru/robonen/syshelper/compare/v0.1.1...v0.2.0 [0.1.1]: https://git.robonen.ru/robonen/syshelper/compare/v0.1.0...v0.1.1 diff --git a/Cargo.lock b/Cargo.lock index 0d29ae8..0a1acaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1224,7 +1224,7 @@ dependencies = [ [[package]] name = "syshelper" -version = "0.3.0" +version = "0.4.0" dependencies = [ "argon2", "axum", diff --git a/Cargo.toml b/Cargo.toml index c6be596..9aa510f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "syshelper" -version = "0.3.0" +version = "0.4.0" edition = "2021" [dependencies] diff --git a/src/dns.rs b/src/dns.rs index 073356c..f2f79ef 100644 --- a/src/dns.rs +++ b/src/dns.rs @@ -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, + /// Куда писать следующую запись; она же — самая старая, когда кольцо полно. + 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 { + 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, pub attempts: Vec, + pub recent: Vec, } /* ── общее состояние ───────────────────────────────────────── */ @@ -450,6 +561,7 @@ pub struct Dns { blocklist: watch::Sender>, counters: Arc, attempts: Arc>, + recent: Arc>, /// Строка с адресом вышестоящего сервера либо причина, почему резолвер не /// работает. Панель должна показывать это раньше, чем родитель заметит, что /// блокировка молчит. @@ -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>, counters: Arc, attempts: Arc>, + recent: Arc>, } 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, 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, }); diff --git a/src/dns_tests.rs b/src/dns_tests.rs index d533060..d121b00 100644 --- a/src/dns_tests.rs +++ b/src/dns_tests.rs @@ -282,6 +282,80 @@ fn a_list_set_before_the_resolver_starts_is_not_lost() { assert!(rx.borrow().matches("gateway.discord.com")); } +/* ── лента последних запросов ──────────────────────────────── */ + +#[test] +fn the_recent_ring_reports_newest_first() { + let mut ring = Recent::default(); + let now = Instant::now(); + for name in ["one.example", "two.example", "three.example"] { + ring.record(name, TYPE_A, Verdict::Upstream, now); + } + + let seen: Vec = ring.snapshot(now).into_iter().map(|q| q.name).collect(); + assert_eq!(seen, ["three.example", "two.example", "one.example"]); +} + +/// Кольцо обязано затирать самое старое, а не расти: иначе запись о каждом +/// запросе в системе съедала бы память тем быстрее, чем активнее машина. +#[test] +fn the_recent_ring_overwrites_the_oldest() { + let mut ring = Recent::default(); + let now = Instant::now(); + for i in 0..RECENT_CAP + 50 { + ring.record(&format!("n{i}.example"), TYPE_A, Verdict::Upstream, now); + } + + let seen = ring.snapshot(now); + assert_eq!(seen.len(), RECENT_CAP, "длина упёрлась в предел"); + assert_eq!(seen[0].name, format!("n{}.example", RECENT_CAP + 49)); + assert_eq!(seen[RECENT_CAP - 1].name, format!("n{}.example", 50)); +} + +#[test] +fn the_recent_ring_keeps_the_record_type() { + let mut ring = Recent::default(); + let now = Instant::now(); + ring.record("a.example", TYPE_AAAA, Verdict::Blocked, now); + ring.record("b.example", 65, Verdict::Cached, now); + + let seen = ring.snapshot(now); + assert_eq!(seen[0].kind, "HTTPS"); + assert_eq!(seen[1].kind, "AAAA"); +} + +/// Имя длиннее массива обязано обрезаться, а не сломать запись. +#[test] +fn an_overlong_name_does_not_break_the_ring() { + let mut ring = Recent::default(); + let now = Instant::now(); + let long = "x".repeat(MAX_NAME + 20); + ring.record(&long, TYPE_A, Verdict::Upstream, now); + + assert_eq!(ring.snapshot(now)[0].name.len(), MAX_NAME); +} + +/// Все три исхода обязаны попадать в ленту разными пометками — иначе по ней +/// нельзя отличить закрытое имя от разрешённого. +#[tokio::test] +async fn every_verdict_reaches_the_recent_ring() { + let hits = Arc::new(AtomicU64::new(0)); + let upstream = fake_upstream(Arc::clone(&hits), 0).await; + let (addr, _stop, ring) = spawn_resolver_watched(upstream, &["discord.com"]).await; + + let _ = ask(addr, "discord.com", 1).await; + let _ = ask(addr, "example.com", 2).await; // уходит наверх + let _ = ask(addr, "example.com", 3).await; // и возвращается из кэша + + let seen = ring.lock().unwrap().snapshot(Instant::now()); + assert_eq!(seen.len(), 3); + assert_eq!(seen[0].name, "example.com"); + assert_eq!(seen[0].verdict, Verdict::Cached, "самый свежий — из кэша"); + assert_eq!(seen[1].verdict, Verdict::Upstream); + assert_eq!(seen[2].name, "discord.com"); + assert_eq!(seen[2].verdict, Verdict::Blocked, "самый старый — закрытый"); +} + /* ── сквозная проверка ─────────────────────────────────────── */ /// Адрес, которым отвечает поддельный сервер: по нему видно, что ответ пришёл @@ -328,19 +402,30 @@ async fn build_resolver(upstream: SocketAddr, blocked: &[&str]) -> Arc blocklist: rx, counters: Arc::default(), attempts: Arc::default(), + recent: Arc::default(), }) } /// Поднимает резолвер на случайном порту и возвращает его адрес. async fn spawn_resolver(upstream: SocketAddr, blocked: &[&str]) -> (SocketAddr, watch::Sender) { + let (addr, stop, _) = spawn_resolver_watched(upstream, blocked).await; + (addr, stop) +} + +/// То же, но отдаёт ещё и ленту последних запросов — она общая с резолвером. +async fn spawn_resolver_watched( + upstream: SocketAddr, + blocked: &[&str], +) -> (SocketAddr, watch::Sender, Arc>) { let client = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap()); let addr = client.local_addr().unwrap(); let resolver = build_resolver(upstream, blocked).await; + let ring = Arc::clone(&resolver.recent); let (stop_tx, stop_rx) = watch::channel(false); tokio::spawn(client_loop(Arc::clone(&resolver), client, stop_rx.clone())); tokio::spawn(upstream_loop(resolver, stop_rx)); - (addr, stop_tx) + (addr, stop_tx, ring) } /// Спрашивает резолвер и возвращает ответ целиком. diff --git a/static/app.js b/static/app.js index 8cf04b7..bd43daa 100644 --- a/static/app.js +++ b/static/app.js @@ -62,7 +62,7 @@ function useLog() { function useSites() { const dns = ref({ listening: false, upstream: '', queries: 0, blocked: 0, - cache_hits: 0, timeouts: 0, error: null, attempts: [], + cache_hits: 0, timeouts: 0, error: null, attempts: [], recent: [], }); const load = async () => { dns.value = await api('/dns'); }; // сам список закрытых сайтов живёт в правилах, здесь — только состояние @@ -259,6 +259,62 @@ const SitesPanel = { `, }; +const QueryLog = { + props: { + entries: { type: Array, default: () => [] }, + active: { type: Boolean, default: false }, + }, + setup(props) { + const filter = ref(''); + const visible = computed(() => { + const q = filter.value.trim().toLowerCase(); + return q ? props.entries.filter((e) => e.name.includes(q)) : props.entries; + }); + + const verdicts = { blocked: 'закрыт', cached: 'из кэша', upstream: 'наверх' }; + const ago = (s) => (s < 60 ? `${s} с` : `${Math.floor(s / 60)} мин`); + + return { filter, visible, verdicts, ago }; + }, + template: ` +
+
+

Последние запросы

+ {{ visible.length }} из {{ entries.length }} +
+

+ Блокировка выключена — резолвер запросов не видит. +

+ +
`, +}; + const RandomKill = { emits: ['schedule'], setup(_, { emit }) { @@ -354,7 +410,7 @@ const EventLog = { /* ── корневой компонент ────────────────────────────────────── */ const App = { - components: { UpdateBar, RulesPanel, SitesPanel, RandomKill, ProcessTable, EventLog }, + components: { UpdateBar, RulesPanel, SitesPanel, QueryLog, RandomKill, ProcessTable, EventLog }, setup() { const { rules, load: loadRules, block, allow, remove, save } = useRules(); const { groups, load: loadProcesses, kill } = useProcesses(); @@ -411,6 +467,7 @@ const App = { @block="blockSite" @remove="removeSite" @enable="save({ sites_enforce: $event })" @lockdown="save({ dns_lockdown: $event })" /> + `,