Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9550aad4f8 | |||
| 2b8e0cb701 |
@@ -3,6 +3,31 @@
|
|||||||
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.
|
||||||
|
|
||||||
|
### 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
|
## [0.3.0] — 2026-08-09
|
||||||
|
|
||||||
"It now closes websites, not just programs."
|
"It now closes websites, not just programs."
|
||||||
@@ -91,6 +116,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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Спрашивает резолвер и возвращает ответ целиком.
|
/// Спрашивает резолвер и возвращает ответ целиком.
|
||||||
|
|||||||
+29
-5
@@ -179,6 +179,15 @@ fn parse_addrs(text: &str) -> Vec<IpAddr> {
|
|||||||
/// достаточно проставить его один раз и обновлять изредка.
|
/// достаточно проставить его один раз и обновлять изредка.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn redirect_to_local() -> Result<(), String> {
|
pub fn redirect_to_local() -> Result<(), String> {
|
||||||
|
// Адаптер, уже смотрящий на нас, не трогаем вовсе, а кэш имён сбрасываем,
|
||||||
|
// только если что-то действительно изменилось. Вызов приходит и по таймеру,
|
||||||
|
// раз в пять минут, и безусловный сброс выбрасывал бы кэш всей системы на
|
||||||
|
// ровном месте — при том, что менять там обычно нечего.
|
||||||
|
//
|
||||||
|
// Сравниваем склейкой в строку, а не массивом: у пустого списка серверов
|
||||||
|
// сравнение массива с адресом даёт «равно», и адаптер без настройки молча
|
||||||
|
// остался бы ненастроенным.
|
||||||
|
//
|
||||||
// Счётчик обязателен. Отдельная попытка на адаптер завёрнута в `try`, иначе
|
// Счётчик обязателен. Отдельная попытка на адаптер завёрнута в `try`, иначе
|
||||||
// одна отключённая виртуальная карта срывала бы всю настройку, — но тогда
|
// одна отключённая виртуальная карта срывала бы всю настройку, — но тогда
|
||||||
// PowerShell завершается успешно, даже не тронув ни одного адаптера, и
|
// PowerShell завершается успешно, даже не тронув ни одного адаптера, и
|
||||||
@@ -186,17 +195,32 @@ pub fn redirect_to_local() -> Result<(), String> {
|
|||||||
// идёт: на машине без IPv6 она падает законно.
|
// идёт: на машине без IPv6 она падает законно.
|
||||||
const SCRIPT: &str = "\
|
const SCRIPT: &str = "\
|
||||||
$ErrorActionPreference='Stop';\
|
$ErrorActionPreference='Stop';\
|
||||||
$n=0;\
|
$n=0;$changed=0;\
|
||||||
foreach($a in Get-NetAdapter){\
|
foreach($a in Get-NetAdapter){\
|
||||||
try{Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('127.0.0.1');$n++}catch{};\
|
try{\
|
||||||
try{Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('::1')}catch{}\
|
$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";
|
$n";
|
||||||
|
|
||||||
|
// Считаются адаптеры, которые в итоге смотрят на нас, а не только те, что
|
||||||
|
// пришлось переставить: холостой проход обязан заканчиваться успехом.
|
||||||
let done = count_from(&powershell(SCRIPT)?);
|
let done = count_from(&powershell(SCRIPT)?);
|
||||||
if done == 0 {
|
if done == 0 {
|
||||||
return Err("ни один адаптер не переключился".to_string());
|
return Err("ни один адаптер не удалось настроить".to_string());
|
||||||
}
|
}
|
||||||
set_marker(true);
|
set_marker(true);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+59
-2
@@ -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" />`,
|
||||||
|
|||||||
Reference in New Issue
Block a user