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
+19
View File
@@ -3,6 +3,24 @@
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
versioning follows [Semantic Versioning](https://semver.org/). 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 ## [0.3.0] — 2026-08-09
"It now closes websites, not just programs." "It now closes websites, not just programs."
@@ -91,6 +109,7 @@ browser."
- Runs as a Windows SYSTEM service and requires 64-bit Windows. - Runs as a Windows SYSTEM service and requires 64-bit Windows.
- Configuration is stored in `C:\ProgramData\SysHelper`. - 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.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.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 [0.1.1]: https://git.robonen.ru/robonen/syshelper/compare/v0.1.0...v0.1.1
Generated
+1 -1
View File
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]] [[package]]
name = "syshelper" name = "syshelper"
version = "0.3.0" version = "0.4.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"axum", "axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "syshelper" name = "syshelper"
version = "0.3.0" version = "0.4.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
+139
View File
@@ -89,6 +89,10 @@ const REPORT_INTERVAL: Duration = Duration::from_secs(30);
const FAILOVER_AFTER: u64 = 5; const FAILOVER_AFTER: u64 = 5;
/// Сколько доменов помнить для панели. /// Сколько доменов помнить для панели.
const STATS_CAP: usize = 256; const STATS_CAP: usize = 256;
/// Сколько последних запросов держать для панели. Двести — это примерно
/// полминуты жизни машины под нагрузкой и несколько часов простоя, то есть
/// ровно то окно, в котором ещё имеет смысл спрашивать «а что сейчас было».
const RECENT_CAP: usize = 200;
/// Потолок одновременных TCP-запросов. По TCP ходят единицы клиентов, но /// Потолок одновременных TCP-запросов. По TCP ходят единицы клиентов, но
/// принимать их без счёта — способ остаться без дескрипторов. /// принимать их без счёта — способ остаться без дескрипторов.
const MAX_TCP: usize = 32; 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)] #[derive(Default)]
@@ -441,6 +551,7 @@ pub struct DnsStatus {
pub timeouts: u64, pub timeouts: u64,
pub error: Option<String>, pub error: Option<String>,
pub attempts: Vec<BlockedDomain>, pub attempts: Vec<BlockedDomain>,
pub recent: Vec<RecentQuery>,
} }
/* ── общее состояние ───────────────────────────────────────── */ /* ── общее состояние ───────────────────────────────────────── */
@@ -450,6 +561,7 @@ pub struct Dns {
blocklist: watch::Sender<Arc<Blocklist>>, blocklist: watch::Sender<Arc<Blocklist>>,
counters: Arc<Counters>, counters: Arc<Counters>,
attempts: Arc<Mutex<Attempts>>, attempts: Arc<Mutex<Attempts>>,
recent: Arc<Mutex<Recent>>,
/// Строка с адресом вышестоящего сервера либо причина, почему резолвер не /// Строка с адресом вышестоящего сервера либо причина, почему резолвер не
/// работает. Панель должна показывать это раньше, чем родитель заметит, что /// работает. Панель должна показывать это раньше, чем родитель заметит, что
/// блокировка молчит. /// блокировка молчит.
@@ -463,6 +575,7 @@ impl Dns {
blocklist, blocklist,
counters: Arc::default(), counters: Arc::default(),
attempts: Arc::default(), attempts: Arc::default(),
recent: Arc::default(),
state: Mutex::new((false, String::new(), None)), state: Mutex::new((false, String::new(), None)),
} }
} }
@@ -486,6 +599,11 @@ impl Dns {
if let Ok(mut s) = self.state.lock() { if let Ok(mut s) = self.state.lock() {
*s = (false, String::new(), None); *s = (false, String::new(), None);
} }
// Лента уходит вместе с резолвером: выключенная блокировка не должна
// оставлять после себя перечень того, что человек открывал.
if let Ok(mut recent) = self.recent.lock() {
*recent = Recent::default();
}
} }
pub fn status(&self) -> DnsStatus { pub fn status(&self) -> DnsStatus {
@@ -512,6 +630,12 @@ impl Dns {
.map(|s| s.clone()) .map(|s| s.clone())
.unwrap_or((false, String::new(), None)); .unwrap_or((false, String::new(), None));
let recent = self
.recent
.lock()
.map(|r| r.snapshot(now))
.unwrap_or_default();
DnsStatus { DnsStatus {
listening, listening,
upstream, upstream,
@@ -521,6 +645,7 @@ impl Dns {
timeouts: self.counters.timeouts.load(Ordering::Relaxed), timeouts: self.counters.timeouts.load(Ordering::Relaxed),
error, error,
attempts, attempts,
recent,
} }
} }
} }
@@ -550,6 +675,7 @@ struct Resolver {
blocklist: watch::Receiver<Arc<Blocklist>>, blocklist: watch::Receiver<Arc<Blocklist>>,
counters: Arc<Counters>, counters: Arc<Counters>,
attempts: Arc<Mutex<Attempts>>, attempts: Arc<Mutex<Attempts>>,
recent: Arc<Mutex<Recent>>,
} }
impl Resolver { impl Resolver {
@@ -557,6 +683,14 @@ impl Resolver {
self.upstreams[self.active.load(Ordering::Relaxed) % self.upstreams.len()] 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`: пока задача разбирает /// Здесь не должно появиться ни одного `await`: пока задача разбирает
@@ -579,6 +713,7 @@ impl Resolver {
attempts.record(name, now); attempts.record(name, now);
} }
self.counters.blocked.fetch_add(1, Ordering::Relaxed); self.counters.blocked.fetch_add(1, Ordering::Relaxed);
self.note(name, q.qtype, Verdict::Blocked, now);
return match write_blocked(pkt, &q) { return match write_blocked(pkt, &q) {
Some(n) => Decision::Answer(n), Some(n) => Decision::Answer(n),
None => Decision::Drop, None => Decision::Drop,
@@ -606,9 +741,12 @@ impl Resolver {
if let Some(n) = cache.get(key, packet_id(pkt), pkt, now) { if let Some(n) = cache.get(key, packet_id(pkt), pkt, now) {
pkt[HEADER..q.end].copy_from_slice(&asked[..asked_len]); pkt[HEADER..q.end].copy_from_slice(&asked[..asked_len]);
self.counters.cache_hits.fetch_add(1, Ordering::Relaxed); self.counters.cache_hits.fetch_add(1, Ordering::Relaxed);
drop(cache); // мьютекс кэша не держим на время записи в ленту
self.note(name, q.qtype, Verdict::Cached, now);
return Decision::Answer(n); return Decision::Answer(n);
} }
} }
self.note(name, q.qtype, Verdict::Upstream, now);
Decision::Forward(Some(key.into())) 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(), blocklist: state.dns.blocklist.subscribe(),
counters: Arc::clone(&state.dns.counters), counters: Arc::clone(&state.dns.counters),
attempts: Arc::clone(&state.dns.attempts), attempts: Arc::clone(&state.dns.attempts),
recent: Arc::clone(&state.dns.recent),
upstreams, upstreams,
}); });
+86 -1
View File
@@ -282,6 +282,80 @@ fn a_list_set_before_the_resolver_starts_is_not_lost() {
assert!(rx.borrow().matches("gateway.discord.com")); 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<String> = 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<Resolver>
blocklist: rx, blocklist: rx,
counters: Arc::default(), counters: Arc::default(),
attempts: Arc::default(), attempts: Arc::default(),
recent: Arc::default(),
}) })
} }
/// Поднимает резолвер на случайном порту и возвращает его адрес. /// Поднимает резолвер на случайном порту и возвращает его адрес.
async fn spawn_resolver(upstream: SocketAddr, blocked: &[&str]) -> (SocketAddr, watch::Sender<bool>) { async fn spawn_resolver(upstream: SocketAddr, blocked: &[&str]) -> (SocketAddr, watch::Sender<bool>) {
let (addr, stop, _) = spawn_resolver_watched(upstream, blocked).await;
(addr, stop)
}
/// То же, но отдаёт ещё и ленту последних запросов — она общая с резолвером.
async fn spawn_resolver_watched(
upstream: SocketAddr,
blocked: &[&str],
) -> (SocketAddr, watch::Sender<bool>, Arc<Mutex<Recent>>) {
let client = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap()); let client = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
let addr = client.local_addr().unwrap(); let addr = client.local_addr().unwrap();
let resolver = build_resolver(upstream, blocked).await; let resolver = build_resolver(upstream, blocked).await;
let ring = Arc::clone(&resolver.recent);
let (stop_tx, stop_rx) = watch::channel(false); let (stop_tx, stop_rx) = watch::channel(false);
tokio::spawn(client_loop(Arc::clone(&resolver), client, stop_rx.clone())); tokio::spawn(client_loop(Arc::clone(&resolver), client, stop_rx.clone()));
tokio::spawn(upstream_loop(resolver, stop_rx)); tokio::spawn(upstream_loop(resolver, stop_rx));
(addr, stop_tx) (addr, stop_tx, ring)
} }
/// Спрашивает резолвер и возвращает ответ целиком. /// Спрашивает резолвер и возвращает ответ целиком.
+59 -2
View File
@@ -62,7 +62,7 @@ function useLog() {
function useSites() { function useSites() {
const dns = ref({ const dns = ref({
listening: false, upstream: '', queries: 0, blocked: 0, 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'); }; const load = async () => { dns.value = await api('/dns'); };
// сам список закрытых сайтов живёт в правилах, здесь — только состояние // сам список закрытых сайтов живёт в правилах, здесь — только состояние
@@ -259,6 +259,62 @@ const SitesPanel = {
</section>`, </section>`,
}; };
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: `
<section class="card">
<div class="row" style="justify-content:space-between">
<h2 style="margin:0">Последние запросы</h2>
<span class="muted">{{ visible.length }} из {{ entries.length }}</span>
</div>
<p v-if="!active" class="muted" style="margin:8px 0 0">
Блокировка выключена — резолвер запросов не видит.
</p>
<template v-else>
<div class="row" style="margin:10px 0">
<input v-model="filter" placeholder="фильтр по имени" style="flex:1">
</div>
<p class="muted" style="margin:0 0 8px">
Хранится в памяти, на диск не пишется и пропадает вместе с выключением.
</p>
<div class="scroll" style="max-height:260px">
<table>
<thead>
<tr><th>Когда</th><th>Имя</th><th>Тип</th><th>Что было</th></tr>
</thead>
<tbody>
<tr v-for="(e, i) in visible" :key="i"
:class="{ blocked: e.verdict === 'blocked' }">
<td class="muted">{{ ago(e.ago_secs) }}</td>
<td>{{ e.name }}</td>
<td class="muted">{{ e.kind }}</td>
<td>{{ verdicts[e.verdict] || e.verdict }}</td>
</tr>
<tr v-if="!visible.length">
<td colspan="4" class="muted">пусто</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>`,
};
const RandomKill = { const RandomKill = {
emits: ['schedule'], emits: ['schedule'],
setup(_, { emit }) { setup(_, { emit }) {
@@ -354,7 +410,7 @@ const EventLog = {
/* ── корневой компонент ────────────────────────────────────── */ /* ── корневой компонент ────────────────────────────────────── */
const App = { const App = {
components: { UpdateBar, RulesPanel, SitesPanel, RandomKill, ProcessTable, EventLog }, components: { UpdateBar, RulesPanel, SitesPanel, QueryLog, RandomKill, ProcessTable, EventLog },
setup() { setup() {
const { rules, load: loadRules, block, allow, remove, save } = useRules(); const { rules, load: loadRules, block, allow, remove, save } = useRules();
const { groups, load: loadProcesses, kill } = useProcesses(); const { groups, load: loadProcesses, kill } = useProcesses();
@@ -411,6 +467,7 @@ const App = {
@block="blockSite" @remove="removeSite" @block="blockSite" @remove="removeSite"
@enable="save({ sites_enforce: $event })" @enable="save({ sites_enforce: $event })"
@lockdown="save({ dns_lockdown: $event })" /> @lockdown="save({ dns_lockdown: $event })" />
<query-log :entries="dns.recent" :active="rules.enforce && rules.sites_enforce" />
<random-kill @schedule="scheduleRandom" /> <random-kill @schedule="scheduleRandom" />
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" /> <process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
<event-log :lines="lines" />`, <event-log :lines="lines" />`,