2 Commits

Author SHA1 Message Date
robonen 9550aad4f8 feat: improve DNS cache handling by avoiding unnecessary flushes
CI / Windows x64 (push) Successful in 12m36s
2026-08-09 16:37:31 +07:00
robonen 2b8e0cb701 feat: add recent query logging and display in UI
CI / Windows x64 (push) Successful in 9m12s
2026-08-09 16:25:09 +07:00
7 changed files with 341 additions and 10 deletions
+26
View File
@@ -3,6 +3,31 @@
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.
### Fixed
- The system DNS cache is no longer flushed on every periodic re-apply. Adapters
that already point at the resolver are left untouched, and the cache is only
cleared when something actually changed — previously the whole machine's cache
was discarded every five minutes for nothing.
### 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 +116,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
Generated
+1 -1
View File
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "syshelper"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"argon2",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "syshelper"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
[dependencies]
+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,
});
+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"));
}
/* ── лента последних запросов ──────────────────────────────── */
#[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,
counters: Arc::default(),
attempts: Arc::default(),
recent: Arc::default(),
})
}
/// Поднимает резолвер на случайном порту и возвращает его адрес.
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 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)
}
/// Спрашивает резолвер и возвращает ответ целиком.
+29 -5
View File
@@ -179,6 +179,15 @@ fn parse_addrs(text: &str) -> Vec<IpAddr> {
/// достаточно проставить его один раз и обновлять изредка.
#[cfg(windows)]
pub fn redirect_to_local() -> Result<(), String> {
// Адаптер, уже смотрящий на нас, не трогаем вовсе, а кэш имён сбрасываем,
// только если что-то действительно изменилось. Вызов приходит и по таймеру,
// раз в пять минут, и безусловный сброс выбрасывал бы кэш всей системы на
// ровном месте — при том, что менять там обычно нечего.
//
// Сравниваем склейкой в строку, а не массивом: у пустого списка серверов
// сравнение массива с адресом даёт «равно», и адаптер без настройки молча
// остался бы ненастроенным.
//
// Счётчик обязателен. Отдельная попытка на адаптер завёрнута в `try`, иначе
// одна отключённая виртуальная карта срывала бы всю настройку, — но тогда
// PowerShell завершается успешно, даже не тронув ни одного адаптера, и
@@ -186,17 +195,32 @@ pub fn redirect_to_local() -> Result<(), String> {
// идёт: на машине без IPv6 она падает законно.
const SCRIPT: &str = "\
$ErrorActionPreference='Stop';\
$n=0;\
$n=0;$changed=0;\
foreach($a in Get-NetAdapter){\
try{Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('127.0.0.1');$n++}catch{};\
try{Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('::1')}catch{}\
try{\
$cur=((Get-DnsClientServerAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv4).ServerAddresses -join ',');\
if($cur -ne '127.0.0.1'){\
Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('127.0.0.1');\
$changed++\
};\
$n++\
}catch{};\
try{\
$cur6=((Get-DnsClientServerAddress -InterfaceIndex $a.ifIndex -AddressFamily IPv6).ServerAddresses -join ',');\
if($cur6 -ne '::1'){\
Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('::1');\
$changed++\
}\
}catch{}\
};\
Clear-DnsClientCache;\
if($changed -gt 0){Clear-DnsClientCache};\
$n";
// Считаются адаптеры, которые в итоге смотрят на нас, а не только те, что
// пришлось переставить: холостой проход обязан заканчиваться успехом.
let done = count_from(&powershell(SCRIPT)?);
if done == 0 {
return Err("ни один адаптер не переключился".to_string());
return Err("ни один адаптер не удалось настроить".to_string());
}
set_marker(true);
Ok(())
+59 -2
View File
@@ -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 = {
</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 = {
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 })" />
<query-log :entries="dns.recent" :active="rules.enforce && rules.sites_enforce" />
<random-kill @schedule="scheduleRandom" />
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
<event-log :lines="lines" />`,