4 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
robonen 0223dd817b feat: add custom dns resolver
CI / Windows x64 (push) Successful in 9m31s
2026-08-09 15:47:38 +07:00
robonen ccaf521da4 feat: update syshelper version to 0.2.0 and enhance firewall rules in installer
CI / Windows x64 (push) Successful in 10m9s
2026-08-02 22:21:49 +07:00
12 changed files with 3151 additions and 18 deletions
+123
View File
@@ -0,0 +1,123 @@
# Changelog
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."
### Added
- Website blocking through a built-in DNS resolver on `127.0.0.1:53` and
`[::1]:53`. One entry closes the domain and every subdomain under it —
`discord.com` also covers `gateway.discord.com`, so there is no list to
maintain by hand.
- A "Sites" section in the panel: resolver status, query counters and a table of
attempts, showing what was reached for and when.
- A separate on/off switch for website blocking. Turning it off frees port 53
and hands the adapters back to the router's settings.
- Optional firewall rule that blocks queries to third-party name servers, so the
resolver cannot simply be bypassed.
- A `--restore-dns` switch that puts network settings back without starting the
service, for the case where the service will not come up.
### Changed
- Vue is compiled into the executable; it used to be fetched from `unpkg.com`.
The panel now works without an internet connection, and a name resolution
failure no longer takes away the only way to manage the service.
- The uninstaller restores DNS settings before removing the service.
### Notes
- **Website blocking is off by default.** Updates arrive on their own, without
anyone at the keyboard, and must not silently rewrite network settings. Turn
it on in the panel.
- The resolver needs port 53. If something else holds it — Docker, Internet
Connection Sharing, a third-party DNS proxy — the panel says so, network
settings are left alone, and program blocking keeps working.
- DNS over HTTPS in browsers and any VPN bypass this layer. Process termination
is what covers those, whitelist mode most reliably.
- Network settings are restored when the service stops. If that fails, it is
recorded in the log under `C:\ProgramData\SysHelper`.
## [0.2.0] — 2026-08-02
"The panel can now be opened from another device on your home network — a phone
or a second computer."
### Added
- Service listens on all network interfaces, with a Windows Firewall rule for
port 8787 scoped to private and domain networks only.
### Notes
- Upgrading: the firewall rule is created automatically during installation and
updates.
- The panel is served over plain HTTP; passwords travel the network unencrypted.
- Local access at `http://127.0.0.1:8787` is unchanged and still password
protected.
## [0.1.1] — 2026-08-02
Installer reliability improvements; service and panel unchanged.
### Fixed
- Setup now properly reports when the service fails to start.
- Setup verifies that the panel password was stored and warns if it was not.
### Notes
- Warnings appear during manual installation only; automatic updates stay
silent and log to `C:\ProgramData\SysHelper`.
## [0.1.0] — 2026-08-02
"Parental control for Windows. Runs as a background service, managed from a
browser."
### Added
- Block programs by name, in blacklist or whitelist mode.
- Schedule termination at a random moment inside a time window.
- Live process list and event log.
- Automatic updates.
### Notes
- 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
[0.1.0]: https://git.robonen.ru/robonen/syshelper/releases/tag/v0.1.0
Generated
+1 -1
View File
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "syshelper"
version = "0.1.1"
version = "0.4.0"
dependencies = [
"argon2",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "syshelper"
version = "0.1.1"
version = "0.4.0"
edition = "2021"
[dependencies]
+42
View File
@@ -234,6 +234,31 @@ begin
'', SW_HIDE, ewWaitUntilTerminated, Code);
end;
// Без правила брандмауэра панель доступна только с самого компьютера: Windows
// режет входящие подключения по умолчанию. Ограничиваемся частными и доменными
// сетями — в публичной (кафе, вокзал) панель наружу смотреть не должна.
procedure RemoveFirewallRule();
var
Code: Integer;
begin
Exec(ExpandConstant('{sys}\netsh.exe'),
'advfirewall firewall delete rule name="{#AppName}"',
'', SW_HIDE, ewWaitUntilTerminated, Code);
end;
procedure OpenFirewallPort();
var
Code: Integer;
begin
// сначала снимаем старое правило, иначе при переустановке они накопятся
RemoveFirewallRule();
Exec(ExpandConstant('{sys}\netsh.exe'),
'advfirewall firewall add rule name="{#AppName}" dir=in action=allow' +
' protocol=TCP localport=8787 profile=private,domain' +
' program="' + ExpandConstant('{app}\{#AppExeName}') + '"',
'', SW_HIDE, ewWaitUntilTerminated, Code);
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
Started: Boolean;
@@ -245,6 +270,7 @@ begin
// только затем поднимаем службу.
LockDownDataDir();
SetPassword();
OpenFirewallPort();
Started := InstallService();
// Обе проверки — про молчаливый отказ: без них установка отрапортует об
@@ -263,11 +289,27 @@ begin
'Откройте «Службы» и посмотрите System Helper.', mbError, MB_OK);
end;
// Пока служба работала, адаптеры смотрели на её резолвер (127.0.0.1). Если
// удалить программу и оставить эту настройку, машина останется без разрешения
// имён вовсе — то есть без интернета. Возврат делает сама программа: ключ
// --restore-dns снимает и настройку адаптеров, и правила брандмауэра.
procedure RestoreDns();
var
Code: Integer;
begin
Exec(ExpandConstant('{app}\{#AppExeName}'), '--restore-dns',
'', SW_HIDE, ewWaitUntilTerminated, Code);
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep <> usUninstall then
Exit;
// Порядок обратный установке: сначала гасим службу, потом возвращаем сеть —
// работающая служба переставила бы настройку обратно на следующем такте.
StopService();
RestoreDns();
RunSc('delete {#ServiceName}');
RemoveFirewallRule();
end;
+1140
View File
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
//! Тесты DNS-резолвера.
//!
//! Оставлены дочерним модулем `dns`, а не вынесены в интеграционные: они лезут
//! в `parse_question`, `Cache` и `Resolver`, и делать всё это публичным только
//! ради проверок означало бы расширить внешнюю поверхность службы без всякой
//! пользы.
use super::*;
/// Собирает запрос: заголовок, имя метками, тип и класс.
fn query(name: &str, qtype: u16) -> Vec<u8> {
let mut pkt = vec![0x12, 0x34, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0];
for label in name.split('.') {
pkt.push(label.len() as u8);
pkt.extend_from_slice(label.as_bytes());
}
pkt.push(0);
pkt.extend_from_slice(&qtype.to_be_bytes());
pkt.extend_from_slice(&CLASS_IN.to_be_bytes());
pkt
}
fn parsed(name: &str, qtype: u16) -> Option<String> {
let pkt = query(name, qtype);
let mut buf = [0u8; MAX_NAME];
let q = parse_question(&pkt, &mut buf)?;
Some(String::from_utf8(buf[..q.len].to_vec()).unwrap())
}
/* ── разбор пакета ─────────────────────────────────────────── */
#[test]
fn name_is_read_and_lowercased() {
assert_eq!(
parsed("Gateway.Discord.GG", TYPE_A).as_deref(),
Some("gateway.discord.gg")
);
}
#[test]
fn truncated_packet_is_rejected() {
let pkt = query("discord.com", TYPE_A);
let mut buf = [0u8; MAX_NAME];
for cut in HEADER..pkt.len() {
assert!(
parse_question(&pkt[..cut], &mut buf).is_none(),
"обрезка до {cut}"
);
}
}
#[test]
fn compression_pointer_in_question_is_rejected() {
let mut pkt = query("discord.com", TYPE_A);
pkt[HEADER] = 0xc0; // длина метки со старшими битами — это указатель
let mut buf = [0u8; MAX_NAME];
assert!(parse_question(&pkt, &mut buf).is_none());
}
#[test]
fn a_response_on_input_is_not_parsed() {
let mut pkt = query("discord.com", TYPE_A);
pkt[2] |= 0x80;
let mut buf = [0u8; MAX_NAME];
assert!(parse_question(&pkt, &mut buf).is_none());
}
/* ── совпадение по списку ──────────────────────────────────── */
#[test]
fn one_entry_covers_every_subdomain() {
let list = Blocklist::new(vec!["discord.com".into()]);
assert!(list.matches("discord.com"));
assert!(list.matches("cdn.discord.com"));
assert!(list.matches("a.b.c.discord.com"));
assert!(!list.matches("discord.com.evil.net"));
assert!(!list.matches("notdiscord.com"));
assert!(!list.matches("example.com"));
}
#[test]
fn a_single_label_does_not_take_down_a_whole_zone() {
let list = Blocklist::new(vec!["com".into()]);
assert!(!list.matches("example.com"));
}
#[test]
fn a_pasted_browser_address_becomes_a_domain() {
assert_eq!(
normalize_domain("https://www.Discord.com/app?x=1").as_deref(),
Some("discord.com")
);
assert_eq!(
normalize_domain(" discord.gg. ").as_deref(),
Some("discord.gg")
);
assert_eq!(
normalize_domain("discord.com:443").as_deref(),
Some("discord.com")
);
assert_eq!(normalize_domain("discord"), None);
assert_eq!(normalize_domain(""), None);
assert_eq!(normalize_domain("a..b"), None);
}
/* ── имена не из латиницы ──────────────────────────────────── */
// В сеть имя уходит только в punycode: браузер превращает `дискорд.рф` в
// `xn--d1aaliume.xn--p1ai` ещё до того, как обратиться к резолверу. Поэтому в
// списке обязана лежать именно эта форма — на кириллицу в запросе резолвер не
// наткнётся никогда.
#[test]
fn a_punycode_address_is_accepted() {
assert_eq!(
normalize_domain("xn--d1aaliume.xn--p1ai").as_deref(),
Some("xn--d1aaliume.xn--p1ai")
);
// Из адресной строки его копируют вместе со схемой, а путь при этом вполне
// может остаться кириллическим — до него разбор доходить не должен.
assert_eq!(
normalize_domain("https://XN--D1AALIUME.XN--P1AI/чат?x=1").as_deref(),
Some("xn--d1aaliume.xn--p1ai")
);
assert_eq!(
normalize_domain("www.xn--h1alffa9f.xn--p1ai").as_deref(),
Some("xn--h1alffa9f.xn--p1ai")
);
}
/// Кириллица отвергается намеренно: до сети такое имя не дойдёт, и запись в
/// списке осталась бы мёртвой. Панель на это отвечает «не похоже на адрес
/// сайта», так что молчаливой потери не происходит.
#[test]
fn a_non_ascii_address_is_refused() {
assert_eq!(normalize_domain("дискорд.рф"), None);
assert_eq!(normalize_domain("https://дискорд.рф/чат"), None);
assert_eq!(normalize_domain("прив.ет"), None);
}
#[test]
fn a_punycode_entry_covers_its_subdomains() {
let list = Blocklist::new(vec!["xn--d1aaliume.xn--p1ai".into()]);
assert!(list.matches("xn--d1aaliume.xn--p1ai"));
assert!(list.matches("www.xn--d1aaliume.xn--p1ai"));
assert!(list.matches("cdn.media.xn--d1aaliume.xn--p1ai"));
assert!(!list.matches("xn--d1aaliume.xn--p1ai.evil.net"));
// Соседнее имя в той же зоне закрывать не должно: `xn--p1ai` — это весь
// домен `.рф`.
assert!(!list.matches("xn--h1alffa9f.xn--p1ai"));
}
/* ── синтезированные ответы ────────────────────────────────── */
#[test]
fn the_stub_answer_carries_a_null_address() {
let mut pkt = query("discord.com", TYPE_A);
pkt.resize(BUF, 0);
let mut name = [0u8; MAX_NAME];
let q = parse_question(&pkt, &mut name).unwrap();
let n = write_blocked(&mut pkt, &q).unwrap();
assert_eq!(packet_id(&pkt), 0x1234, "идентификатор клиента сохраняется");
assert_eq!(pkt[2] & 0x80, 0x80, "это ответ");
assert_eq!(rcode(&pkt), 0, "ошибки нет");
assert_eq!(
u16::from_be_bytes([pkt[6], pkt[7]]),
1,
"ровно одна запись в ответе"
);
assert_eq!(&pkt[n - 4..n], &[0, 0, 0, 0], "адрес 0.0.0.0");
assert_eq!(
&pkt[q.end..q.end + 2],
&[0xc0, 0x0c],
"имя записано ссылкой на вопрос"
);
}
#[test]
fn other_types_get_an_empty_success() {
let mut pkt = query("discord.com", 15); // MX
pkt.resize(BUF, 0);
let mut name = [0u8; MAX_NAME];
let q = parse_question(&pkt, &mut name).unwrap();
let n = write_blocked(&mut pkt, &q).unwrap();
assert_eq!(n, q.end, "ответ пуст");
assert_eq!(u16::from_be_bytes([pkt[6], pkt[7]]), 0);
assert_eq!(rcode(&pkt), 0);
}
/// Заглушка не имеет права выдавать адрес из класса IN в ответ на вопрос о
/// другом классе.
#[test]
fn the_stub_invents_no_address_for_a_foreign_class() {
const CLASS_CH: u16 = 3;
let mut pkt = query("discord.com", TYPE_A);
let class_at = pkt.len() - 2;
pkt[class_at..].copy_from_slice(&CLASS_CH.to_be_bytes());
pkt.resize(BUF, 0);
let mut name = [0u8; MAX_NAME];
let q = parse_question(&pkt, &mut name).unwrap();
let n = write_blocked(&mut pkt, &q).unwrap();
assert_eq!(n, q.end, "ответ пуст");
assert_eq!(u16::from_be_bytes([pkt[6], pkt[7]]), 0);
}
/* ── кэш ───────────────────────────────────────────────────── */
#[test]
fn the_cache_stamps_the_asking_client_id() {
let mut cache = Cache::default();
let now = Instant::now();
let mut key = [0u8; MAX_NAME + 4];
let len = cache_key(b"example.com", TYPE_A, CLASS_IN, &mut key);
let mut stored = query("example.com", TYPE_A);
stored[2] = 0x81; // ответ, рекурсия запрошена
stored[3] = 0x80;
cache.put(&key[..len], &stored, now);
let mut out = [0u8; BUF];
let n = cache.get(&key[..len], 0xbeef, &mut out, now).unwrap();
assert_eq!(packet_id(&out[..n]), 0xbeef);
assert_eq!(&out[2..n], &stored[2..]);
}
#[test]
fn expired_entries_are_not_served() {
let mut cache = Cache::default();
let now = Instant::now();
let mut key = [0u8; MAX_NAME + 4];
let len = cache_key(b"example.com", TYPE_A, CLASS_IN, &mut key);
let mut stored = query("example.com", TYPE_A);
stored[2] = 0x81;
stored[3] = 0x80;
cache.put(&key[..len], &stored, now);
let mut out = [0u8; BUF];
assert!(cache
.get(&key[..len], 1, &mut out, now + CACHE_TTL)
.is_none());
}
#[test]
fn server_failures_are_not_cached() {
let mut cache = Cache::default();
let now = Instant::now();
let mut key = [0u8; MAX_NAME + 4];
let len = cache_key(b"example.com", TYPE_A, CLASS_IN, &mut key);
let mut stored = query("example.com", TYPE_A);
stored[2] = 0x81;
stored[3] = 0x82; // SERVFAIL
cache.put(&key[..len], &stored, now);
let mut out = [0u8; BUF];
assert!(cache.get(&key[..len], 1, &mut out, now).is_none());
}
#[test]
fn record_types_do_not_share_a_cache_entry() {
let mut a = [0u8; MAX_NAME + 4];
let mut aaaa = [0u8; MAX_NAME + 4];
let la = cache_key(b"example.com", TYPE_A, CLASS_IN, &mut a);
let lq = cache_key(b"example.com", TYPE_AAAA, CLASS_IN, &mut aaaa);
assert_ne!(&a[..la], &aaaa[..lq]);
}
/// Список задают до того, как резолвер на него подпишется: правила загружаются
/// при старте службы, а резолвер поднимается только при включении блокировки.
/// Значение обязано дожить до подписчика.
#[test]
fn a_list_set_before_the_resolver_starts_is_not_lost() {
let dns = Dns::new();
dns.set_domains(["discord.com".to_string()]);
let rx = dns.blocklist.subscribe();
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, "самый старый — закрытый");
}
/* ── сквозная проверка ─────────────────────────────────────── */
/// Адрес, которым отвечает поддельный сервер: по нему видно, что ответ пришёл
/// сверху, а не собран здесь же.
const REAL_IP: [u8; 4] = [93, 184, 216, 34];
/// Поддельный вышестоящий сервер. Считает обращения — так видно, сходил ли
/// резолвер наверх или обошёлся своими силами. `pad` дописывает в конец ответа
/// лишние байты: так проверяется, что длинные ответы доходят целыми.
async fn fake_upstream(hits: Arc<AtomicU64>, pad: usize) -> SocketAddr {
let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let addr = sock.local_addr().unwrap();
tokio::spawn(async move {
let mut buf = vec![0u8; BUF];
while let Ok((len, from)) = sock.recv_from(&mut buf).await {
hits.fetch_add(1, Ordering::Relaxed);
let mut name = [0u8; MAX_NAME];
let Some(q) = parse_question(&buf[..len], &mut name) else {
continue;
};
let n = write_blocked(&mut buf, &q).unwrap();
buf[n - 4..n].copy_from_slice(&REAL_IP);
for (i, slot) in buf[n..n + pad].iter_mut().enumerate() {
*slot = (i % 251) as u8;
}
let _ = sock.send_to(&buf[..n + pad], from).await;
}
});
addr
}
async fn build_resolver(upstream: SocketAddr, blocked: &[&str]) -> Arc<Resolver> {
let domains: Vec<String> = blocked.iter().map(|s| s.to_string()).collect();
let (_tx, rx) = watch::channel(Arc::new(Blocklist::new(domains)));
// Отправителя нарочно роняем: `borrow` продолжает отдавать последнее
// значение, и это заодно проверяет, что резолвер не падает без него.
Arc::new(Resolver {
upstream_sock: Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap()),
upstreams: vec![upstream],
active: AtomicUsize::new(0),
inflight: Mutex::new(HashMap::new()),
next_id: AtomicU16::new(1000),
cache: Mutex::new(Cache::default()),
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, ring)
}
/// Спрашивает резолвер и возвращает ответ целиком.
async fn ask(to: SocketAddr, name: &str, id: u16) -> Vec<u8> {
ask_typed(to, name, TYPE_A, CLASS_IN, id).await
}
async fn ask_typed(to: SocketAddr, name: &str, qtype: u16, qclass: u16, id: u16) -> Vec<u8> {
let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let mut pkt = query(name, qtype);
set_packet_id(&mut pkt, id);
let end = pkt.len();
pkt[end - 2..].copy_from_slice(&qclass.to_be_bytes());
sock.send_to(&pkt, to).await.unwrap();
let mut buf = vec![0u8; BUF];
let (len, _) = tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf))
.await
.expect("резолвер не ответил")
.unwrap();
buf.truncate(len);
buf
}
#[tokio::test]
async fn an_allowed_name_goes_upstream_and_comes_back_to_the_asker() {
let hits = Arc::new(AtomicU64::new(0));
let upstream = fake_upstream(Arc::clone(&hits), 0).await;
let (addr, _stop) = spawn_resolver(upstream, &["discord.com"]).await;
let reply = ask(addr, "example.com", 0xaaaa).await;
assert_eq!(packet_id(&reply), 0xaaaa, "клиенту вернулся его же номер");
assert_eq!(&reply[reply.len() - 4..], &REAL_IP, "ответ пришёл сверху");
assert_eq!(hits.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn a_blocked_name_never_reaches_the_upstream() {
let hits = Arc::new(AtomicU64::new(0));
let upstream = fake_upstream(Arc::clone(&hits), 0).await;
let (addr, _stop) = spawn_resolver(upstream, &["discord.com"]).await;
let reply = ask(addr, "gateway.discord.com", 0xbbbb).await;
assert_eq!(packet_id(&reply), 0xbbbb);
assert_eq!(&reply[reply.len() - 4..], &[0, 0, 0, 0], "адрес нулевой");
assert_eq!(
hits.load(Ordering::Relaxed),
0,
"запрещённое имя не должно доходить до вышестоящего сервера"
);
}
/// Кириллический домен приходит от браузера уже в punycode и в произвольном
/// регистре. Резолвер обязан узнать его и закрыть, ничего не спрашивая наверх.
#[tokio::test]
async fn a_punycode_name_is_blocked_on_the_wire() {
let hits = Arc::new(AtomicU64::new(0));
let upstream = fake_upstream(Arc::clone(&hits), 0).await;
let (addr, _stop) = spawn_resolver(upstream, &["xn--d1aaliume.xn--p1ai"]).await;
let reply = ask(addr, "WWW.XN--D1AALIUME.XN--P1AI", 0xcccc).await;
assert_eq!(packet_id(&reply), 0xcccc);
assert_eq!(&reply[reply.len() - 4..], &[0, 0, 0, 0], "адрес нулевой");
assert_eq!(
hits.load(Ordering::Relaxed),
0,
"запрещённое имя не должно доходить до вышестоящего сервера"
);
}
#[tokio::test]
async fn a_repeated_question_is_served_from_cache() {
let hits = Arc::new(AtomicU64::new(0));
let upstream = fake_upstream(Arc::clone(&hits), 0).await;
let (addr, _stop) = spawn_resolver(upstream, &[]).await;
let first = ask(addr, "example.com", 1).await;
let second = ask(addr, "example.com", 2).await;
assert_eq!(packet_id(&second), 2, "номер второго спросившего");
assert_eq!(first[2..], second[2..], "тело ответа то же");
assert_eq!(
hits.load(Ordering::Relaxed),
1,
"наверх сходили один раз на двоих"
);
}
#[tokio::test]
async fn answers_are_not_swapped_between_askers() {
let hits = Arc::new(AtomicU64::new(0));
let upstream = fake_upstream(Arc::clone(&hits), 0).await;
let (addr, _stop) = spawn_resolver(upstream, &[]).await;
// Разные имена, чтобы кэш не помог, и одновременно — чтобы наверх ушли оба
// запроса и вернулись вперемешку.
let (a, b) = tokio::join!(
ask(addr, "one.example", 0x0101),
ask(addr, "two.example", 0x0202)
);
assert_eq!(packet_id(&a), 0x0101);
assert_eq!(packet_id(&b), 0x0202);
// Ответ повторяет вопрос, поэтому по секции вопроса видно, что каждому
// вернулось именно его имя, а не соседнее.
for (reply, name) in [(&a, "one.example"), (&b, "two.example")] {
let asked = query(name, TYPE_A);
assert_eq!(&reply[HEADER..asked.len()], &asked[HEADER..], "{name}");
}
}
/// Ответ длиннее четырёх килобайт обязан дойти целым. Приёмный буфер меньше
/// нужного отбросил бы хвост молча, и клиент получил бы обломок пакета.
#[tokio::test]
async fn a_long_reply_is_not_truncated() {
const PAD: usize = 5000;
let hits = Arc::new(AtomicU64::new(0));
let upstream = fake_upstream(Arc::clone(&hits), PAD).await;
let (addr, _stop) = spawn_resolver(upstream, &[]).await;
let reply = ask(addr, "big.example", 0x7777).await;
assert!(reply.len() > 4096, "ответ пришёл длиной {}", reply.len());
assert_eq!(packet_id(&reply), 0x7777);
// Хвост-заполнитель проверяем побайтно: обрезка проявилась бы именно тут.
let tail = &reply[reply.len() - PAD..];
assert!(
tail.iter().enumerate().all(|(i, b)| *b == (i % 251) as u8),
"хвост ответа испорчен"
);
}
/// В кэше ключ приведён к нижнему регистру, а сам пакет — нет. Второму
/// спросившему обязан вернуться его собственный вопрос: клиент вправе не
/// признать ответ, в котором имя написано иначе, чем он спрашивал.
#[tokio::test]
async fn the_cache_returns_the_asker_s_own_question() {
let hits = Arc::new(AtomicU64::new(0));
let upstream = fake_upstream(Arc::clone(&hits), 0).await;
let (addr, _stop) = spawn_resolver(upstream, &[]).await;
let _ = ask(addr, "Example.COM", 1).await;
let second = ask(addr, "example.com", 2).await;
assert_eq!(hits.load(Ordering::Relaxed), 1, "второй ответ — из кэша");
let asked = query("example.com", TYPE_A);
assert_eq!(
&second[HEADER..asked.len()],
&asked[HEADER..],
"вернулся чужой регистр имени"
);
}
/// Один и тот же вопрос в разных классах — разные записи. Общий ключ выдал бы
/// на вопрос про класс CH ответ, полученный для IN.
#[tokio::test]
async fn the_query_class_does_not_collide_in_the_cache() {
const CLASS_CH: u16 = 3;
let hits = Arc::new(AtomicU64::new(0));
let upstream = fake_upstream(Arc::clone(&hits), 0).await;
let (addr, _stop) = spawn_resolver(upstream, &[]).await;
let _ = ask_typed(addr, "version.bind", TYPE_A, CLASS_IN, 1).await;
let _ = ask_typed(addr, "version.bind", TYPE_A, CLASS_CH, 2).await;
assert_eq!(
hits.load(Ordering::Relaxed),
2,
"второй класс обязан уйти наверх, а не взяться из кэша первого"
);
}
/* ── замер производительности ──────────────────────────────── */
/// Считает, во что обходится решение по одному запросу на каждом из трёх путей,
/// и сколько запросов в секунду держит резолвер целиком.
///
/// Из общего прогона исключён: меряет он машину не меньше, чем код, и на слабой
/// или занятой числа поплывут. Запуск:
/// `cargo test --release -- --ignored --nocapture benchmark`
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn benchmark() {
const CPU_ROUNDS: u32 = 200_000;
const TRIPS: u32 = 20_000;
const CLIENTS: u32 = 32;
let hits = Arc::new(AtomicU64::new(0));
let upstream = fake_upstream(Arc::clone(&hits), 0).await;
/* работа процессора без ввода-вывода — собственно цена решения */
let res = build_resolver(upstream, &["blocked.example"]).await;
let mut buf = vec![0u8; BUF];
// Кэш наполняем заранее: меряем попадание, а не промах.
let warm = query("cached.example", TYPE_A);
buf[..warm.len()].copy_from_slice(&warm);
let _ = res.decide(&mut buf, warm.len(), Instant::now());
{
let mut name = [0u8; MAX_NAME];
let q = parse_question(&warm, &mut name).unwrap();
let mut key = [0u8; MAX_NAME + 4];
let klen = cache_key(b"cached.example", q.qtype, q.qclass, &mut key);
let mut answer = warm.clone();
answer[2] = 0x81;
answer[3] = 0x80;
res.cache
.lock()
.unwrap()
.put(&key[..klen], &answer, Instant::now());
}
let mut report = Vec::new();
for (label, name) in [
("запрещённое имя", "blocked.example"),
("попадание в кэш", "cached.example"),
("уходит наверх", "fresh.example"),
] {
let pkt = query(name, TYPE_A);
let start = std::time::Instant::now();
for _ in 0..CPU_ROUNDS {
buf[..pkt.len()].copy_from_slice(&pkt);
std::hint::black_box(res.decide(&mut buf, pkt.len(), Instant::now()));
}
let each = start.elapsed() / CPU_ROUNDS;
report.push(format!(" {label:<16} {:>7} нс/запрос", each.as_nanos()));
}
/* сквозной путь через сокеты */
let (addr, _stop) = spawn_resolver(upstream, &["blocked.example"]).await;
for (label, name, unique) in [
("запрещённое имя", "blocked.example", false),
("попадание в кэш", "warm.example", false),
("уходит наверх", "aaaa.example", true),
] {
let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let mut pkt = query(name, TYPE_A);
let mut got = vec![0u8; BUF];
// Для пути наверх имя обязано быть новым каждый раз, иначе со второго
// запроса отвечает кэш и меряется совсем не то. Меняем четыре байта
// первой метки — длина пакета при этом не плывёт.
let vary = |pkt: &mut [u8], i: u32| {
for slot in 0..4 {
pkt[HEADER + 1 + slot] = b'a' + ((i >> (slot * 5)) % 26) as u8;
}
};
if !unique {
// прогрев: первый запрос наполняет кэш
sock.send_to(&pkt, addr).await.unwrap();
sock.recv_from(&mut got).await.unwrap();
}
let start = std::time::Instant::now();
for i in 0..TRIPS {
set_packet_id(&mut pkt, i as u16);
if unique {
vary(&mut pkt, i);
}
sock.send_to(&pkt, addr).await.unwrap();
sock.recv_from(&mut got).await.unwrap();
}
let each = start.elapsed() / TRIPS;
report.push(format!(
" {label:<16} {:>7} мкс туда-обратно",
each.as_micros()
));
}
/* пропускная способность: много клиентов сразу */
let start = std::time::Instant::now();
let mut tasks = Vec::new();
for c in 0..CLIENTS {
tasks.push(tokio::spawn(async move {
let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let mut pkt = query("blocked.example", TYPE_A);
let mut got = vec![0u8; BUF];
for i in 0..TRIPS / CLIENTS {
set_packet_id(&mut pkt, (c * 1000 + i) as u16);
sock.send_to(&pkt, addr).await.unwrap();
sock.recv_from(&mut got).await.unwrap();
}
}));
}
for t in tasks {
t.await.unwrap();
}
let done = (TRIPS / CLIENTS) * CLIENTS;
let rate = done as f64 / start.elapsed().as_secs_f64();
println!("\nцена решения (без сокетов):");
for line in &report[..3] {
println!("{line}");
}
println!("\nсквозь сокеты, по одному запросу за раз:");
for line in &report[3..] {
println!("{line}");
}
println!("\nпропускная способность: {rate:.0} запросов/с при {CLIENTS} клиентах\n");
}
+347 -11
View File
@@ -29,11 +29,18 @@ use sysinfo::{Pid, System};
use tokio::sync::Mutex;
mod auth;
mod dns;
mod netcfg;
#[cfg(windows)]
mod service;
mod update;
const ADDR: &str = "127.0.0.1:8787";
/// Слушаем все интерфейсы, чтобы панель открывалась и с телефона или другого
/// компьютера в домашней сети. Правило брандмауэра ставит установщик, и только
/// для частных сетей — в публичной панель наружу смотреть не должна.
const BIND_ADDR: &str = "0.0.0.0:8787";
/// Адрес для ярлыков и сообщений: 0.0.0.0 в браузер не введёшь.
const PANEL_URL: &str = "http://127.0.0.1:8787";
/// Служебные команды не должны мигать консольным окном.
#[cfg(windows)]
pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000;
@@ -47,6 +54,11 @@ const LOG_FILE_LIMIT: u64 = 1024 * 1024;
// фронт вшивается в exe на этапе компиляции
const INDEX_HTML: &str = include_str!("../static/index.html");
const APP_JS: &str = include_str!("../static/app.js");
/// Vue лежит рядом с исходниками, а не грузится с CDN. Причина не в скорости:
/// панель — единственный способ управлять службой, и брать её часть из сети
/// означало бы, что при поломке разрешения имён (а этим служба как раз и
/// занимается) чинить будет уже нечем. Заодно панель работает без интернета.
const VUE_JS: &str = include_str!("../static/vue.global.prod.js");
/// Нельзя убивать никогда — иначе BSOD или сломанный рабочий стол.
/// Список имён — только первый рубеж, основной барьер в `is_system_path`.
@@ -125,19 +137,17 @@ fn stamp() -> String {
/// Открывает панель в браузере. Своего окна у программы нет, поэтому запуск
/// ярлыком без этого выглядел бы как «ничего не произошло».
fn open_panel() {
let url = format!("http://{ADDR}");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// пустой заголовок обязателен: иначе start примет URL за имя окна
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "", &url])
.args(["/C", "start", "", PANEL_URL])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(not(windows))]
let _ = std::process::Command::new("open").arg(&url).spawn();
let _ = std::process::Command::new("open").arg(PANEL_URL).spawn();
}
/// Окна нет, поэтому паника иначе прошла бы совершенно незаметно: процесс
@@ -193,6 +203,24 @@ struct Rules {
blocked: HashSet<String>, // всегда в нижнем регистре
allowed: HashSet<String>,
enforce: bool,
/// Домены, закрытые через свой DNS. Запись закрывает и поддомены.
///
/// `serde(default)` здесь обязателен: без него правила, записанные прежней
/// версией, перестали бы читаться, а `Rules::load` молча подставил бы
/// пустые — то есть обновление стёрло бы все настройки родителя.
#[serde(default)]
blocked_sites: HashSet<String>,
/// Включена ли блокировка сайтов целиком.
///
/// По умолчанию выключена, и это осознанно: обновление приходит само, без
/// участия человека, и не должно молча переставлять настройки сети на
/// машине, где о новой возможности ещё не знают. Включает родитель в панели.
#[serde(default)]
sites_enforce: bool,
/// Запрещать ли обращения к чужим DNS-серверам мимо нас. По умолчанию нет:
/// правило трогает брандмауэр, и цена ошибки — машина без интернета.
#[serde(default)]
dns_lockdown: bool,
}
impl Default for Rules {
@@ -202,6 +230,9 @@ impl Default for Rules {
blocked: HashSet::new(),
allowed: HashSet::new(),
enforce: true,
blocked_sites: HashSet::new(),
sites_enforce: false,
dns_lockdown: false,
}
}
}
@@ -220,6 +251,12 @@ impl Rules {
}
}
/// Работает ли сейчас блокировка сайтов. Общая галочка «применять правила»
/// старше частной: выключив её, родитель ожидает, что выключилось всё.
fn sites_blocking(&self) -> bool {
self.enforce && self.sites_enforce
}
fn violates(&self, name_lower: &str) -> bool {
match self.mode {
Mode::Blacklist => self.blocked.contains(name_lower),
@@ -243,10 +280,24 @@ struct AppState {
log: Mutex<Vec<String>>,
update: Mutex<update::UpdateStatus>,
auth: auth::Auth,
dns: dns::Dns,
}
type Shared = Arc<AppState>;
/// Передаёт резолверу то, что должно быть закрыто прямо сейчас.
///
/// Снятая галочка «применять правила» обязана выключать и блокировку сайтов:
/// иначе панель показывала бы одно, а резолвер делал другое. Список правил при
/// этом сохраняется — выключение не должно стирать то, что родитель набирал.
fn sync_dns(rules: &Rules, dns: &dns::Dns) {
if rules.sites_blocking() {
dns.set_domains(rules.blocked_sites.iter().cloned());
} else {
dns.set_domains(std::iter::empty());
}
}
impl AppState {
async fn log(&self, msg: impl Into<String>) {
let line = format!("[{}] {}", stamp(), msg.into());
@@ -337,6 +388,194 @@ async fn watchdog(state: Shared, mut shutdown: tokio::sync::watch::Receiver<bool
}
}
/* ── согласование настроек сети ────────────────────────────── */
/// Как часто сверять системную настройку с правилами.
const DNS_TICK: Duration = Duration::from_secs(5);
/// Через сколько таких тактов переставлять настройку заново, даже если ничего
/// не менялось.
const DNS_REAPPLY_EVERY: u32 = 60;
/// Поднимает резолвер и держит настройку сети в согласии с правилами.
///
/// Отдельная задача, а не часть `watchdog`: там такт в две секунды, а здесь
/// каждое действие — запуск PowerShell на несколько сотен миллисекунд.
async fn dns_manager(state: Shared, mut shutdown: tokio::sync::watch::Receiver<bool>) {
// Каждый вызов netcfg — это запуск внешней программы на сотни миллисекунд.
// Прямо в задаче он занял бы поток рантайма, а вместе с ним встал бы и приём
// DNS-запросов на том же потоке: раз в пять минут вся машина замирала бы на
// полсекунды. Поэтому всё сюда — через `spawn_blocking`.
let Some(found) = blocking(netcfg::discover_upstreams).await else {
return;
};
let upstreams = found.servers;
// `intent` — чего мы хотели в прошлый раз, `applied` — что вышло. Различать
// их нужно, чтобы неудачная попытка не повторялась каждые пять секунд.
// Начальное значение берём из состояния системы, а не из нуля: адаптеры
// могли остаться переключёнными с прошлого запуска, и тогда выключенная
// блокировка обязана их вернуть.
let mut intent = found.redirected;
let mut applied = found.redirected;
let mut lockdown = false;
let mut ticks: u32 = 0;
// Пока фича выключена, резолвера нет вовсе: порт 53 свободен для чужих
// программ, которым он тоже бывает нужен. `Some` — сигнальный конец, по
// которому его задачи расходятся.
let mut resolver: Option<tokio::sync::watch::Sender<bool>> = None;
// Такт, раньше которого повторять попытку занять порт не нужно.
let mut retry_at: u32 = 0;
loop {
let (asked, asked_lockdown) = {
let rules = state.rules.lock().await;
(rules.sites_blocking(), rules.dns_lockdown)
};
let mut want = asked;
if !asked {
retry_at = 0; // после выключения пробуем сразу, а не через паузу
// Заодно убираем прошлую ошибку: выключенная фича не должна
// отчитываться о том, что у неё что-то не получилось.
state.dns.mark_stopped();
}
// Порядок на включении: сначала занять порт, и только потом уводить на
// себя адаптеры. Не поднялись — не трогаем сеть вовсе.
if want && resolver.is_none() {
if ticks < retry_at {
want = false;
} else {
let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
if dns::run(Arc::clone(&state), upstreams.clone(), stop_rx).await {
resolver = Some(stop_tx);
} else {
// Порт мог занять кто-то, кто его ещё освободит, поэтому
// попытки продолжаем — но редко: каждая пишет строку в
// журнал, и раз в пять секунд она вытеснила бы оттуда всё
// остальное.
retry_at = ticks + DNS_REAPPLY_EVERY;
want = false;
}
}
}
// Запрет чужих серверов имеет смысл, только пока имена разрешаем мы
// сами. Оставить его при выключенной блокировке — верный способ
// получить машину, которая не разрешает имён вообще.
let want_lockdown = asked_lockdown && want;
// Настройку переставляем и без изменений: адаптер могли добавить
// позже — поставить VPN или вторую сетевую карту, — и он ушёл бы мимо
// резолвера, а вместе с ним и весь запрещённый список.
//
// В повтор входит и обратный случай, `applied` без `want`: неудавшийся
// возврат настроек обязан повториться сам. Иначе он защёлкивался бы
// навсегда, и машина осталась бы направлена на резолвер, которого
// после остановки службы уже нет.
let periodic = ticks.is_multiple_of(DNS_REAPPLY_EVERY) && (want || applied);
if want != intent || periodic {
intent = want;
let outcome = blocking(move || {
if want {
netcfg::redirect_to_local()
} else {
netcfg::restore()
}
})
.await
.unwrap_or_else(|| Err("задача настройки сети сорвалась".to_string()));
match (outcome, want) {
(Ok(()), true) if !applied => {
applied = true;
state.log("DNS: адаптеры переведены на локальный резолвер").await;
}
(Ok(()), true) => applied = true,
(Ok(()), false) if applied => {
applied = false;
state.log("DNS: адаптеры возвращены к настройке роутера").await;
}
(Ok(()), false) => {}
(Err(e), _) => state.log(format!("DNS: настройка сети не удалась: {e}")).await,
}
}
// Гасим резолвер только после того, как адаптеры перестали на него
// смотреть. Обратный порядок оставил бы промежуток, в котором система
// спрашивает имена у уже закрытого порта.
if !want && !applied {
if let Some(stop) = resolver.take() {
let _ = stop.send(true);
state.dns.mark_stopped();
state.log("DNS: блокировка сайтов выключена, порт 53 свободен").await;
}
}
if want_lockdown != lockdown {
lockdown = want_lockdown;
let list = upstreams.clone();
let outcome = blocking(move || netcfg::set_lockdown(lockdown, &list))
.await
.unwrap_or_else(|| Err("задача настройки брандмауэра сорвалась".to_string()));
match outcome {
Ok(()) if lockdown => {
state.log("DNS: чужие серверы имён закрыты брандмауэром").await
}
Ok(()) => state.log("DNS: запрет чужих серверов имён снят").await,
Err(e) => state.log(format!("DNS: правило брандмауэра не удалось: {e}")).await,
}
}
ticks = ticks.wrapping_add(1);
tokio::select! {
_ = tokio::time::sleep(DNS_TICK) => {}
_ = shutdown.changed() => break,
}
}
// Служба останавливается — систему нужно оставить в рабочем виде. Забыть об
// этом означало бы машину без разрешения имён вообще. Резолвер гасим после
// возврата настроек, по той же причине, что и выше.
if applied || lockdown {
let outcome = blocking(move || {
let restored = if applied { netcfg::restore() } else { Ok(()) };
if lockdown {
netcfg::clear_firewall();
}
restored
})
.await
.unwrap_or_else(|| Err("задача возврата настроек сорвалась".to_string()));
match outcome {
Ok(()) => state.log("DNS: настройки сети возвращены").await,
// Метка на диске осталась, поэтому следующий запуск службы увидит
// незакрытое переключение и повторит возврат. Молчать здесь нельзя:
// до тех пор машина спрашивает имена у порта, которого уже нет.
Err(e) => {
state
.log(format!("DNS: ВЕРНУТЬ НАСТРОЙКИ НЕ УДАЛОСЬ ({e}) — имена не будут разрешаться, помогает syshelper.exe --restore-dns"))
.await
}
}
}
if let Some(stop) = resolver.take() {
let _ = stop.send(true);
state.dns.mark_stopped();
}
}
/// Уводит блокирующий вызов на отдельный поток. `None` означает, что поток
/// сорвался, — вызывающему решать, что это значит.
async fn blocking<T, F>(work: F) -> Option<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(work).await.ok()
}
/* ── статика ───────────────────────────────────────────────── */
async fn index() -> Html<&'static str> {
@@ -344,9 +583,17 @@ async fn index() -> Html<&'static str> {
}
async fn app_js() -> impl IntoResponse {
javascript(APP_JS)
}
async fn vue_js() -> impl IntoResponse {
javascript(VUE_JS)
}
fn javascript(body: &'static str) -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/javascript; charset=utf-8")],
APP_JS,
body,
)
}
@@ -395,10 +642,20 @@ async fn set_rules(
) -> Json<serde_json::Value> {
new_rules.blocked = new_rules.blocked.iter().map(|s| s.to_lowercase()).collect();
new_rules.allowed = new_rules.allowed.iter().map(|s| s.to_lowercase()).collect();
// Домены нормализуем и здесь: панель шлёт правила целиком, и через этот
// путь в список попал бы адрес со схемой и путём, который ни с чем не
// совпадёт.
new_rules.blocked_sites = new_rules
.blocked_sites
.iter()
.filter_map(|s| dns::normalize_domain(s))
.collect();
{
let mut rules = state.rules.lock().await;
*rules = new_rules;
rules.save();
sync_dns(&rules, &state.dns);
}
state.log("правила обновлены").await;
Json(json!({ "ok": true }))
@@ -456,6 +713,54 @@ async fn rule_remove(
Json(json!({ "ok": true, "name": name }))
}
/* ── сайты ─────────────────────────────────────────────────── */
/// Закрывает домен вместе со всеми поддоменами.
async fn site_block(
State(state): State<Shared>,
Json(req): Json<NameReq>,
) -> Json<serde_json::Value> {
let Some(domain) = dns::normalize_domain(&req.name) else {
return Json(json!({ "ok": false, "error": "не похоже на адрес сайта" }));
};
{
let mut rules = state.rules.lock().await;
rules.blocked_sites.insert(domain.clone());
rules.save();
// Резолвер подхватывает список сразу — ждать следующего обхода незачем.
sync_dns(&rules, &state.dns);
}
state.log(format!("сайт закрыт: {domain}")).await;
Json(json!({ "ok": true, "name": domain }))
}
async fn site_remove(
State(state): State<Shared>,
Json(req): Json<NameReq>,
) -> Json<serde_json::Value> {
// Убираем и по нормализованному имени, и по введённому: в списке могла
// остаться запись, добавленная до того, как появилась нормализация.
let typed = req.name.trim().to_lowercase();
let domain = dns::normalize_domain(&req.name);
{
let mut rules = state.rules.lock().await;
rules.blocked_sites.remove(&typed);
if let Some(d) = &domain {
rules.blocked_sites.remove(d);
}
rules.save();
sync_dns(&rules, &state.dns);
}
state.log(format!("сайт открыт: {typed}")).await;
Json(json!({ "ok": true, "name": typed }))
}
async fn dns_status(State(state): State<Shared>) -> Json<dns::DnsStatus> {
Json(state.dns.status())
}
async fn kill_now(
State(state): State<Shared>,
Json(req): Json<NameReq>,
@@ -576,6 +881,18 @@ fn main() {
Err(e) => append_log_file(&format!("[{}] нет рантайма: {e}", stamp())),
}
}
// Возврат настроек сети без запуска службы. Нужен установщику при
// удалении и как ручная аварийная кнопка: если служба почему-то не
// поднимается, адаптеры остаются смотреть в никуда, и без этого ключа
// чинить машину пришлось бы вручную через свойства адаптера.
Some("--restore-dns") => {
let note = match netcfg::restore() {
Ok(()) => "настройки DNS возвращены".to_string(),
Err(e) => format!("не удалось вернуть DNS: {e}"),
};
netcfg::clear_firewall();
append_log_file(&format!("[{}] {note}", stamp()));
}
// Установщик задаёт пароль так, а не аргументом: аргумент был бы виден
// в списке процессов. Файл нам отдают во временном каталоге установки.
Some("--set-password-file") => match args.get(1) {
@@ -612,12 +929,12 @@ async fn bind_panel(
) -> Option<tokio::net::TcpListener> {
let mut complained = false;
loop {
match tokio::net::TcpListener::bind(ADDR).await {
match tokio::net::TcpListener::bind(BIND_ADDR).await {
Ok(listener) => return Some(listener),
Err(e) if !complained => {
complained = true; // в журнал раз, а не каждые полминуты
state
.log(format!("{ADDR} занят ({e}) — жду, правила при этом работают"))
.log(format!("{BIND_ADDR} занят ({e}) — жду, правила при этом работают"))
.await;
}
Err(_) => {}
@@ -633,24 +950,31 @@ async fn bind_panel(
/// Поднимает панель и фоновые задачи. Завершается по сигналу из `shutdown` —
/// его шлёт SCM при остановке службы.
async fn serve(shutdown: tokio::sync::watch::Receiver<bool>) {
let rules = Rules::load();
let state: Shared = Arc::new(AppState {
rules: Mutex::new(Rules::load()),
rules: Mutex::new(rules),
sys: Mutex::new(System::new_all()),
// журнал переживает перезапуск службы — иначе после обновления панель
// показывала бы пустоту и было бы неясно, что вообще произошло
log: Mutex::new(load_log_tail()),
update: Mutex::new(update::UpdateStatus::new()),
auth: auth::Auth::load(),
dns: dns::Dns::new(),
});
sync_dns(&*state.rules.lock().await, &state.dns);
let app = Router::new()
.route("/", get(index))
.route("/app.js", get(app_js))
.route("/vue.js", get(vue_js))
.route("/processes", get(list_processes))
.route("/rules", get(get_rules).post(set_rules))
.route("/rules/block", post(rule_block))
.route("/rules/allow", post(rule_allow))
.route("/rules/remove", post(rule_remove))
.route("/sites/block", post(site_block))
.route("/sites/remove", post(site_remove))
.route("/dns", get(dns_status))
.route("/kill", post(kill_now))
.route("/kill/pid", post(kill_pid))
.route("/kill/random", post(kill_random))
@@ -672,6 +996,9 @@ async fn serve(shutdown: tokio::sync::watch::Receiver<bool>) {
// применяются, а панель дождётся освобождения порта.
tokio::spawn(watchdog(Arc::clone(&state), shutdown.clone()));
tokio::spawn(update::run(Arc::clone(&state)));
// Резолвер поднимаем здесь же: он тоже не должен зависеть от того, удалось
// ли занять порт панели.
let dns_task = tokio::spawn(dns_manager(Arc::clone(&state), shutdown.clone()));
state.log("служба запущена").await;
state
@@ -679,9 +1006,15 @@ async fn serve(shutdown: tokio::sync::watch::Receiver<bool>) {
.await;
let Some(listener) = bind_panel(&state, shutdown.clone()).await else {
return; // службу остановили, пока ждали порт
// Службу остановили, пока ждали порт. Уйти молча нельзя: адаптеры уже
// могут смотреть на нас, и без возврата настроек машина осталась бы
// без разрешения имён.
let _ = dns_task.await;
return;
};
state.log(format!("панель на http://{ADDR}")).await;
state
.log(format!("панель на {PANEL_URL} и на порту 8787 в локальной сети"))
.await;
let mut stop = shutdown.clone();
let result = axum::serve(listener, app)
@@ -693,5 +1026,8 @@ async fn serve(shutdown: tokio::sync::watch::Receiver<bool>) {
if let Err(e) = result {
append_log_file(&format!("[{}] сервер остановлен: {e}", stamp()));
}
// Ждём именно здесь: рантайм вот-вот остановится, и возврат настроек сети
// не успел бы доработать.
let _ = dns_task.await;
state.log("служба остановлена").await;
}
+468
View File
@@ -0,0 +1,468 @@
//! Настройка сети Windows под свой резолвер.
//!
//! Резолвер сам по себе бесполезен: система должна начать спрашивать имена
//! именно у него. Здесь три отдельные обязанности — узнать, кого спрашивать
//! самим, перевести адаптеры на 127.0.0.1 и (по желанию) закрыть обход через
//! чужие DNS-серверы.
//!
//! Всё делается через `Get-NetAdapter`/`Set-DnsClientServerAddress`, а не через
//! разбор вывода `netsh`: последний переведён на язык системы, и на русской
//! Windows разбор колонок разъехался бы. Команды PowerShell стоят сотни
//! миллисекунд, но вызываются они на смене настроек и раз в несколько минут,
//! а не на запросах, — на путь разрешения имён это не влияет.
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
#[cfg(windows)]
use std::{os::windows::process::CommandExt, process::Command};
#[cfg(windows)]
use crate::CREATE_NO_WINDOW;
/// Куда обращаться, если своих серверов найти не удалось.
const FALLBACK: [Ipv4Addr; 2] = [Ipv4Addr::new(1, 1, 1, 1), Ipv4Addr::new(8, 8, 8, 8)];
/// Общее имя правил брандмауэра: по нему же они и снимаются.
#[cfg_attr(not(windows), allow(dead_code))]
pub const FIREWALL_RULE: &str = "SysHelper DNS";
fn upstream_file() -> std::path::PathBuf {
crate::data_dir().join("dns.json")
}
/// Метка «адаптеры переключены на нас и ещё не возвращены».
///
/// Выводить это косвенно нельзя. Пустой опрос системы означает и «адаптеры
/// смотрят на петлю», и «сети на машине нет вовсе» — а по второму признаку
/// программа сбросила бы на DHCP настройку, которую в глаза не видела. Файл
/// пишется в момент переключения и удаляется при возврате, так что врать ему
/// не о чем.
fn marker_file() -> std::path::PathBuf {
crate::data_dir().join("dns_active")
}
#[cfg_attr(not(windows), allow(dead_code))]
fn set_marker(active: bool) {
if active {
let _ = std::fs::write(marker_file(), b"1");
} else {
let _ = std::fs::remove_file(marker_file());
}
}
/* ── вышестоящие серверы ───────────────────────────────────── */
pub struct Upstreams {
pub servers: Vec<SocketAddr>,
/// Адаптеры переключены на нас и ещё не возвращены — так говорит метка,
/// оставленная прошлым запуском. Если закрывать больше нечего, настройку
/// надо снять: иначе адаптеры остались бы направлены на резолвер после
/// того, как родитель выключил блокировку.
pub redirected: bool,
}
/// Находит серверы, которым переадресовывать запросы.
///
/// Живому опросу верим больше, чем запомненному: сеть могла смениться. Но и
/// запоминаем каждый удачный опрос — на случай, если однажды он не даст ничего
/// (например, службу подняли раньше сетевой подсистемы).
pub fn discover_upstreams() -> Upstreams {
let live = probe_system();
if !live.is_empty() {
remember(&live);
}
let stored = std::fs::read_to_string(upstream_file())
.ok()
.and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
.map(|list| {
list.iter()
.filter_map(|s| s.parse::<IpAddr>().ok())
.filter(usable)
.collect::<Vec<_>>()
})
.unwrap_or_default();
Upstreams {
servers: with_port(with_fallback(if live.is_empty() { stored } else { live })),
redirected: marker_file().exists(),
}
}
/// Дописывает публичные серверы в хвост списка.
///
/// Две причины, и обе про машину, которую унесли из дома. Первая: запомненный
/// адрес роутера в чужой сети недостижим, и без запасного варианта имена
/// перестают разрешаться совсем. Вторая: переход на запасной сервер в
/// `janitor` включается только при длине списка больше единицы — с одним
/// адресом он был бы мёртвым кодом.
///
/// Порядок важен: свои серверы идут первыми, потому что только они знают имена
/// внутри домашней сети.
fn with_fallback(mut addrs: Vec<IpAddr>) -> Vec<IpAddr> {
for extra in FALLBACK.map(IpAddr::V4) {
if !addrs.contains(&extra) {
addrs.push(extra);
}
}
addrs
}
fn with_port(addrs: Vec<IpAddr>) -> Vec<SocketAddr> {
addrs.into_iter().map(|ip| SocketAddr::new(ip, 53)).collect()
}
/// Петля — это мы сами, и спрашивать себя означало бы вечный круг. Неуказанный
/// адрес и локальные адреса канала как сервер тоже смысла не имеют.
fn usable(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => !v4.is_loopback() && !v4.is_unspecified() && !v4.is_link_local(),
IpAddr::V6(v6) => !v6.is_loopback() && !v6.is_unspecified(),
}
}
fn remember(addrs: &[IpAddr]) {
let list: Vec<String> = addrs.iter().map(IpAddr::to_string).collect();
if let Ok(raw) = serde_json::to_string(&list) {
let _ = std::fs::write(upstream_file(), raw);
}
}
/// Спрашивает у системы, кому она сама отдаёт запросы имён.
///
/// Три источника подряд, и третий — главный. `Get-DnsClientServerAddress`
/// возвращает действующую настройку, а после переключения она наша, то есть
/// 127.0.0.1, и опрос дал бы пустоту. Значения от DHCP лежат в реестре
/// отдельным ключом, наш статический адрес их не затирает — по ним и видно
/// настоящий сервер, в том числе после переезда машины в другую сеть.
#[cfg(windows)]
fn probe_system() -> Vec<IpAddr> {
const SCRIPT: &str = "\
$ErrorActionPreference='SilentlyContinue';\
$i=(Get-NetRoute -DestinationPrefix '0.0.0.0/0'|Sort-Object RouteMetric|Select-Object -First 1).InterfaceIndex;\
$a=@();\
if($i){$a+=(Get-DnsClientServerAddress -InterfaceIndex $i -AddressFamily IPv4).ServerAddresses};\
$a+=(Get-DnsClientServerAddress -AddressFamily IPv4).ServerAddresses;\
foreach($k in Get-ChildItem 'HKLM:\\SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters\\Interfaces'){\
$v=(Get-ItemProperty $k.PSPath).DhcpNameServer;\
if($v){$a+=($v -split '[ ,]')}\
};\
$a|Where-Object{$_}|Select-Object -Unique";
parse_addrs(&powershell(SCRIPT).unwrap_or_default())
}
#[cfg(not(windows))]
fn probe_system() -> Vec<IpAddr> {
Vec::new()
}
#[cfg_attr(not(windows), allow(dead_code))]
fn parse_addrs(text: &str) -> Vec<IpAddr> {
let mut out = Vec::new();
for line in text.lines() {
if let Ok(ip) = line.trim().parse::<IpAddr>() {
if usable(&ip) && !out.contains(&ip) {
out.push(ip);
}
}
}
out
}
/* ── переключение адаптеров ────────────────────────────────── */
/// Переводит адаптеры на локальный резолвер.
///
/// Настройка ставится всем адаптерам, а не только поднятым: Wi-Fi, который
/// подключат через час, и VPN, который поставят завтра, иначе прошли бы мимо
/// блокировки. Статический адрес сервера переживает переподключение, так что
/// достаточно проставить его один раз и обновлять изредка.
#[cfg(windows)]
pub fn redirect_to_local() -> Result<(), String> {
// Адаптер, уже смотрящий на нас, не трогаем вовсе, а кэш имён сбрасываем,
// только если что-то действительно изменилось. Вызов приходит и по таймеру,
// раз в пять минут, и безусловный сброс выбрасывал бы кэш всей системы на
// ровном месте — при том, что менять там обычно нечего.
//
// Сравниваем склейкой в строку, а не массивом: у пустого списка серверов
// сравнение массива с адресом даёт «равно», и адаптер без настройки молча
// остался бы ненастроенным.
//
// Счётчик обязателен. Отдельная попытка на адаптер завёрнута в `try`, иначе
// одна отключённая виртуальная карта срывала бы всю настройку, — но тогда
// PowerShell завершается успешно, даже не тронув ни одного адаптера, и
// вызывающий рапортовал бы о защите, которой нет. Шестая версия в счёт не
// идёт: на машине без IPv6 она падает законно.
const SCRIPT: &str = "\
$ErrorActionPreference='Stop';\
$n=0;$changed=0;\
foreach($a in Get-NetAdapter){\
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{}\
};\
if($changed -gt 0){Clear-DnsClientCache};\
$n";
// Считаются адаптеры, которые в итоге смотрят на нас, а не только те, что
// пришлось переставить: холостой проход обязан заканчиваться успехом.
let done = count_from(&powershell(SCRIPT)?);
if done == 0 {
return Err("ни один адаптер не удалось настроить".to_string());
}
set_marker(true);
Ok(())
}
/// Последнее число в выводе скрипта.
#[cfg_attr(not(windows), allow(dead_code))]
fn count_from(text: &str) -> u32 {
text.lines()
.rev()
.find_map(|line| line.trim().parse::<u32>().ok())
.unwrap_or(0)
}
/// Возвращает адаптерам настройку от роутера.
///
/// Возврат именно к DHCP, а не к тому, что стояло раньше: различить заданный
/// вручную адрес и полученный по DHCP штатными средствами нельзя, а домашняя
/// машина в подавляющем большинстве случаев получает DNS от роутера. Прежние
/// адреса лежат в `dns.json` — если настройка была ручной, её видно там.
#[cfg(windows)]
pub fn restore() -> Result<(), String> {
const SCRIPT: &str = "\
$ErrorActionPreference='Stop';\
$n=0;\
foreach($a in Get-NetAdapter){\
try{Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ResetServerAddresses;$n++}catch{}\
};\
Clear-DnsClientCache;\
$n";
let done = count_from(&powershell(SCRIPT)?);
if done == 0 {
return Err("ни один адаптер не вернулся к настройке роутера".to_string());
}
// Метку снимаем только после удачного возврата: иначе следующий запуск
// счёл бы систему чистой и не повторил бы попытку.
set_marker(false);
Ok(())
}
#[cfg(not(windows))]
pub fn redirect_to_local() -> Result<(), String> {
Err("переключение DNS есть только в Windows".to_string())
}
#[cfg(not(windows))]
pub fn restore() -> Result<(), String> {
Ok(())
}
/* ── запрет обхода ─────────────────────────────────────────── */
/// Дополняет список адресов до полного диапазона IPv4.
///
/// Брандмауэр Windows не умеет исключений: правило «запретить» всегда сильнее
/// правила «разрешить», поэтому «всем нельзя, а нам можно» одной парой правил не
/// выразить. Зато `remoteip` принимает диапазоны — значит, запрет пишется как
/// «весь IPv4, кроме вот этих адресов».
#[cfg_attr(not(windows), allow(dead_code))]
fn complement_ranges(allowed: &[Ipv4Addr]) -> Vec<String> {
let mut points: Vec<u32> = allowed.iter().map(|ip| u32::from(*ip)).collect();
points.sort_unstable();
points.dedup();
let mut ranges = Vec::new();
let mut start: u32 = 0;
for point in points {
if point > start {
ranges.push(format!(
"{}-{}",
Ipv4Addr::from(start),
Ipv4Addr::from(point - 1)
));
}
// Разрешённый адрес может оказаться последним возможным — тогда
// продолжать некуда, и остаток диапазона пуст.
match point.checked_add(1) {
Some(next) => start = next,
None => return ranges,
}
}
ranges.push(format!("{}-{}", Ipv4Addr::from(start), Ipv4Addr::from(u32::MAX)));
ranges
}
/// Закрывает обращения к чужим DNS-серверам мимо нас.
///
/// Без этого запрет обходится в три клика: программа, знающая адрес 8.8.8.8,
/// спросит его напрямую. Правило рискованное — ошибка в диапазонах оставит
/// машину без разрешения имён, — поэтому включается отдельной галочкой, а не
/// само собой.
#[cfg(windows)]
pub fn set_lockdown(on: bool, upstreams: &[SocketAddr]) -> Result<(), String> {
clear_firewall();
if !on {
return Ok(());
}
let allowed: Vec<Ipv4Addr> = upstreams
.iter()
.filter_map(|addr| match addr.ip() {
IpAddr::V4(v4) => Some(v4),
IpAddr::V6(_) => None,
})
.collect();
if allowed.is_empty() {
return Err("нет ни одного адреса, которому можно оставить доступ".to_string());
}
let ranges = complement_ranges(&allowed).join(",");
for (proto, port) in [("UDP", "53"), ("TCP", "53"), ("TCP", "853")] {
netsh(&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={FIREWALL_RULE}"),
"dir=out",
"action=block",
&format!("protocol={proto}"),
&format!("remoteport={port}"),
&format!("remoteip={ranges}"),
])?;
}
// Шестая версия закрывается целиком: наверх мы ходим по IPv4, так что
// исключать в ней нечего, а оставленная открытой она и была бы обходом.
for proto in ["UDP", "TCP"] {
netsh(&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={FIREWALL_RULE}"),
"dir=out",
"action=block",
&format!("protocol={proto}"),
"remoteport=53,853",
"remoteip=::/0",
])?;
}
// Windows 11 умеет сама уводить запросы в DNS поверх HTTPS — тогда они
// пройдут по 443-му порту мимо всех правил выше.
let _ = reg_add(
r"HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters",
"EnableAutoDoh",
"0",
);
Ok(())
}
#[cfg(windows)]
pub fn clear_firewall() {
let _ = netsh(&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={FIREWALL_RULE}"),
]);
// Значение вернуть в прежнее нельзя — по умолчанию его вообще нет, а
// оставленное после удаления программы оно молча меняло бы поведение
// Windows. Поэтому именно удаляем, а не пишем ноль.
let _ = reg_delete(
r"HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters",
"EnableAutoDoh",
);
}
#[cfg(not(windows))]
pub fn set_lockdown(_on: bool, _upstreams: &[SocketAddr]) -> Result<(), String> {
Err("правила брандмауэра есть только в Windows".to_string())
}
#[cfg(not(windows))]
pub fn clear_firewall() {}
/* ── запуск внешних команд ─────────────────────────────────── */
#[cfg(windows)]
fn powershell(script: &str) -> Result<String, String> {
let out = Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[cfg(windows)]
fn netsh(args: &[&str]) -> Result<(), String> {
let out = Command::new("netsh.exe")
.args(args)
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
}
#[cfg(windows)]
fn reg_add(key: &str, name: &str, value: &str) -> Result<(), String> {
let ok = Command::new("reg.exe")
.args(["add", key, "/v", name, "/t", "REG_DWORD", "/d", value, "/f"])
.creation_flags(CREATE_NO_WINDOW)
.status()
.map_err(|e| e.to_string())?
.success();
ok.then_some(()).ok_or_else(|| "reg add не сработал".to_string())
}
/// Убирает значение. Отсутствие тоже считаем успехом: убрать то, чего нет, —
/// ровно тот результат, которого добивались.
#[cfg(windows)]
fn reg_delete(key: &str, name: &str) -> Result<(), String> {
Command::new("reg.exe")
.args(["delete", key, "/v", name, "/f"])
.creation_flags(CREATE_NO_WINDOW)
.status()
.map_err(|e| e.to_string())
.map(|_| ())
}
#[cfg(test)]
#[path = "netcfg_tests.rs"]
mod tests;
+92
View File
@@ -0,0 +1,92 @@
//! Тесты настройки сети.
//!
//! Покрыто только то, что представляет собой чистую логику. Всё, что уходит в
//! PowerShell и netsh, требует живой Windows, поэтому проверять стоит ровно то,
//! что было бы неверно на любой платформе: арифметику адресов и разбор вывода
//! внешних команд.
use super::*;
fn v4(s: &str) -> Ipv4Addr {
s.parse().unwrap()
}
/* ── диапазоны для брандмауэра ─────────────────────────────── */
#[test]
fn the_complement_steps_over_the_allowed_address() {
let ranges = complement_ranges(&[v4("1.1.1.1")]);
assert_eq!(ranges, vec!["0.0.0.0-1.1.1.0", "1.1.1.2-255.255.255.255"]);
}
#[test]
fn the_complement_merges_adjacent_addresses() {
let ranges = complement_ranges(&[v4("8.8.8.8"), v4("8.8.8.9")]);
assert_eq!(ranges, vec!["0.0.0.0-8.8.8.7", "8.8.8.10-255.255.255.255"]);
}
#[test]
fn the_complement_ignores_order_and_duplicates() {
let ordered = complement_ranges(&[v4("1.1.1.1"), v4("8.8.8.8")]);
let shuffled = complement_ranges(&[v4("8.8.8.8"), v4("1.1.1.1"), v4("8.8.8.8")]);
assert_eq!(ordered, shuffled);
assert_eq!(
ordered,
vec![
"0.0.0.0-1.1.1.0",
"1.1.1.2-8.8.8.7",
"8.8.8.9-255.255.255.255"
]
);
}
#[test]
fn the_edges_of_the_range_do_not_overflow() {
assert_eq!(
complement_ranges(&[v4("0.0.0.0")]),
vec!["0.0.0.1-255.255.255.255"]
);
assert_eq!(
complement_ranges(&[v4("255.255.255.255")]),
vec!["0.0.0.0-255.255.255.254"]
);
assert_eq!(
complement_ranges(&[v4("0.0.0.0"), v4("255.255.255.255")]),
vec!["0.0.0.1-255.255.255.254"]
);
}
/* ── поиск вышестоящих серверов ────────────────────────────── */
#[test]
fn public_servers_are_appended_at_the_end() {
let router: IpAddr = "192.168.1.1".parse().unwrap();
let list = with_fallback(vec![router]);
assert_eq!(list[0], router, "свой сервер остаётся первым");
assert_eq!(list.len(), 3, "и есть куда переходить при отказе");
}
#[test]
fn no_duplicates_appear_in_the_server_list() {
let cloudflare: IpAddr = "1.1.1.1".parse().unwrap();
assert_eq!(with_fallback(vec![cloudflare]).len(), 2);
}
#[test]
fn loopback_and_the_null_address_make_no_upstream() {
let parsed = parse_addrs("127.0.0.1\n0.0.0.0\n192.168.1.1\n\n192.168.1.1\nмусор\n");
assert_eq!(parsed, vec!["192.168.1.1".parse::<IpAddr>().unwrap()]);
}
/* ── вывод внешних команд ──────────────────────────────────── */
#[test]
fn the_adapter_count_is_read_from_the_tail_of_the_output() {
assert_eq!(count_from("WARNING: что-то\n3\n"), 3);
assert_eq!(count_from("0"), 0);
// Пустой или неожиданный вывод обязан читаться как «ничего не вышло»:
// иначе сбой PowerShell выглядел бы как успешное переключение.
assert_eq!(count_from(""), 0);
assert_eq!(count_from("Access is denied."), 0);
}
+187 -4
View File
@@ -17,7 +17,12 @@ async function api(path, body) {
/* ── composables ───────────────────────────────────────────── */
function useRules() {
const rules = ref({ mode: 'blacklist', blocked: [], allowed: [], enforce: true });
// форма объекта должна совпадать с серверной с самого начала: `save` шлёт
// правила целиком, и отсутствующее здесь поле сервер прочитал бы как пустое
const rules = ref({
mode: 'blacklist', blocked: [], allowed: [], enforce: true,
blocked_sites: [], sites_enforce: false, dns_lockdown: false,
});
const load = async () => { rules.value = await api('/rules'); };
const block = (name) => api('/rules/block', { name }).then(load);
const allow = (name) => api('/rules/allow', { name }).then(load);
@@ -54,6 +59,19 @@ function useLog() {
return { lines, load };
}
function useSites() {
const dns = ref({
listening: false, upstream: '', queries: 0, blocked: 0,
cache_hits: 0, timeouts: 0, error: null, attempts: [], recent: [],
});
const load = async () => { dns.value = await api('/dns'); };
// сам список закрытых сайтов живёт в правилах, здесь — только состояние
// резолвера и попытки; поэтому после правки перечитываем и то, и другое
const block = (name) => api('/sites/block', { name });
const remove = (name) => api('/sites/remove', { name });
return { dns, load, block, remove };
}
function useUpdate() {
const info = ref({ current: '', latest: null, available: false, phase: 'idle', checked_at: null, error: null });
const load = async () => { info.value = await api('/update'); };
@@ -152,6 +170,151 @@ const RulesPanel = {
</section>`,
};
const SitesPanel = {
components: { NameChips },
props: {
sites: { type: Array, default: () => [] },
dns: { type: Object, required: true },
// enabled — сама галочка, active — работает ли блокировка на деле:
// общий выключатель «применять правила» гасит и её
enabled: { type: Boolean, default: false },
active: { type: Boolean, default: false },
lockdown: { type: Boolean, default: false },
},
emits: ['block', 'remove', 'enable', 'lockdown'],
setup(props, { emit }) {
const input = ref('');
const submit = () => {
const value = input.value.trim();
if (!value) return;
emit('block', value);
input.value = '';
};
const status = computed(() => {
const d = props.dns;
if (props.enabled && !props.active) return 'выключено общей галочкой «применять правила»';
if (!props.active) return 'выключено — служба имена не разрешает';
if (d.error) return d.error;
if (!d.listening) return 'резолвер запускается…';
return `спрашиваю ${d.upstream} · запросов ${d.queries}, ` +
`отклонено ${d.blocked}, из кэша ${d.cache_hits}` +
(d.timeouts ? `, без ответа ${d.timeouts}` : '');
});
const ago = (s) => (s < 60 ? `${s} с назад` : `${Math.floor(s / 60)} мин назад`);
return {
input, submit, status, ago,
// тревожно только то, что должно работать, но не работает
bad: computed(() => props.active && (props.dns.error || !props.dns.listening)),
idle: computed(() => !props.active && props.sites.length > 0),
};
},
template: `
<section class="card">
<div class="row" style="justify-content:space-between">
<h2 style="margin:0">Сайты</h2>
<label class="row">
<input type="checkbox" :checked="enabled"
@change="$emit('enable', $event.target.checked)">
блокировать сайты
</label>
</div>
<div :class="bad ? 'warn' : 'muted'" style="margin:8px 0 10px">{{ status }}</div>
<p v-if="idle" class="warn" style="margin:0 0 10px">
Список составлен, но блокировка выключена — сайты открываются.
</p>
<div class="row">
<input v-model="input" placeholder="discord.com" style="flex:1;min-width:140px"
@keyup.enter="submit">
<button @click="submit">Закрыть</button>
</div>
<p class="muted" style="margin:8px 0 0">
Запись закрывает и поддомены: <code>discord.com</code> закроет и
<code>gateway.discord.com</code>.
</p>
<name-chips title="Закрытые сайты" :names="sites" @remove="$emit('remove', $event)" />
<label class="row" style="margin-top:12px">
<input type="checkbox" :checked="lockdown" :disabled="!active"
@change="$emit('lockdown', $event.target.checked)">
<span :class="{ muted: !active }">запретить обход через чужие серверы имён</span>
</label>
<p v-if="lockdown && active" class="warn" style="margin:6px 0 0">
Правило брандмауэра закрывает 53-й порт всем, кроме службы. Если интернет
пропадёт — снимите галочку.
</p>
<div v-if="dns.attempts.length" class="scroll" style="margin-top:12px;max-height:180px">
<table>
<thead><tr><th>Куда ломились</th><th>Раз</th><th>Когда</th></tr></thead>
<tbody>
<tr v-for="a in dns.attempts" :key="a.name">
<td>{{ a.name }}</td>
<td>{{ a.count }}</td>
<td class="muted">{{ ago(a.ago_secs) }}</td>
</tr>
</tbody>
</table>
</div>
</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 }) {
@@ -247,15 +410,16 @@ const EventLog = {
/* ── корневой компонент ────────────────────────────────────── */
const App = {
components: { UpdateBar, RulesPanel, 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();
const { lines, load: loadLog } = useLog();
const { info: update, load: loadUpdate, check: checkUpdate } = useUpdate();
const { dns, load: loadDns, block: blockSite, remove: removeSite } = useSites();
onMounted(loadRules);
usePolling(() => { loadProcesses(); loadLog(); }, 3000);
usePolling(() => { loadProcesses(); loadLog(); loadDns(); }, 3000);
// версия меняется раз в сутки в лучшем случае — частить незачем
usePolling(loadUpdate, 30000);
@@ -276,7 +440,18 @@ const App = {
const whitelist = computed(() => rules.value.mode === 'whitelist');
return { rules, groups, lines, update, whitelist, save, remove, toggle, kill, scheduleRandom, checkUpdate };
// правка списка сайтов меняет и правила, и показания резолвера
const onSite = async (action, name) => {
await action(name);
await Promise.all([loadRules(), loadDns()]);
};
return {
rules, groups, lines, update, dns, whitelist,
save, remove, toggle, kill, scheduleRandom, checkUpdate,
blockSite: (name) => onSite(blockSite, name),
removeSite: (name) => onSite(removeSite, name),
};
},
template: `
<div class="row" style="justify-content:space-between">
@@ -285,6 +460,14 @@ const App = {
</div>
<update-bar :info="update" @check="checkUpdate" />
<rules-panel :rules="rules" @update="save" @remove="remove" />
<sites-panel :sites="rules.blocked_sites" :dns="dns"
:enabled="rules.sites_enforce"
:active="rules.enforce && rules.sites_enforce"
:lockdown="rules.dns_lockdown"
@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" />`,
+5 -1
View File
@@ -39,11 +39,15 @@
.warn { color:#e0b872; }
pre { margin:0; font:12px/1.6 ui-monospace, Consolas, monospace; color:#9aa4b2;
white-space:pre-wrap; max-height:200px; overflow:auto; }
code { font:12px/1.5 ui-monospace, Consolas, monospace; color:#c8d0da;
background:#232833; border-radius:4px; padding:1px 4px; }
</style>
</head>
<body>
<div id="app" v-cloak></div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<!-- Vue отдаёт сама служба: панель не должна зависеть от сети, тем более от
разрешения имён, которым служба и управляет. -->
<script src="/vue.js"></script>
<script src="/app.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long