Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0223dd817b | |||
| ccaf521da4 | |||
| dfecd5dafb | |||
| 6d01e5ccc2 | |||
| 8da02e2392 |
@@ -0,0 +1,97 @@
|
||||
# Changelog
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
versioning follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [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.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
@@ -1224,7 +1224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syshelper"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "syshelper"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+74
-3
@@ -185,7 +185,18 @@ begin
|
||||
SW_HIDE, ewWaitUntilTerminated, Code);
|
||||
end;
|
||||
|
||||
procedure InstallService();
|
||||
// Запускаем через net start, а не sc start, по той же причине, что и остановку:
|
||||
// net дожидается фактического запуска и возвращает ошибку, если он не удался.
|
||||
// sc отдаёт управление сразу, и сломанная установка выглядела бы успешной.
|
||||
function StartService(): Boolean;
|
||||
var
|
||||
Code: Integer;
|
||||
begin
|
||||
Result := Exec(ExpandConstant('{sys}\net.exe'), 'start {#ServiceName}', '',
|
||||
SW_HIDE, ewWaitUntilTerminated, Code) and (Code = 0);
|
||||
end;
|
||||
|
||||
function InstallService(): Boolean;
|
||||
var
|
||||
Bin: String;
|
||||
begin
|
||||
@@ -204,7 +215,8 @@ begin
|
||||
RunSc('description {#ServiceName} "Родительский контроль: следит за запуском программ."');
|
||||
// Родительский контроль не должен исчезать из-за одной аварии.
|
||||
RunSc('failure {#ServiceName} reset= 86400 actions= restart/5000/restart/5000/restart/60000');
|
||||
RunSc('start {#ServiceName}');
|
||||
|
||||
Result := StartService();
|
||||
end;
|
||||
|
||||
// Каталог данных закрываем от обычных пользователей: унаследованные от
|
||||
@@ -222,7 +234,34 @@ 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;
|
||||
begin
|
||||
if CurStep <> ssPostInstall then
|
||||
Exit;
|
||||
@@ -231,7 +270,35 @@ begin
|
||||
// только затем поднимаем службу.
|
||||
LockDownDataDir();
|
||||
SetPassword();
|
||||
InstallService();
|
||||
OpenFirewallPort();
|
||||
Started := InstallService();
|
||||
|
||||
// Обе проверки — про молчаливый отказ: без них установка отрапортует об
|
||||
// успехе, а защиты не будет. При тихой установке (автообновление) человека
|
||||
// за экраном нет, и разбираться придётся по журналу Inno, /LOG= его пишет.
|
||||
if WizardSilent() then
|
||||
Exit;
|
||||
|
||||
if not AuthConfigured() then
|
||||
MsgBox('Не удалось сохранить пароль панели.'#13#10 +
|
||||
'Панель откроется без пароля, и задать его сможет любой, ' +
|
||||
'кто зайдёт первым.', mbError, MB_OK);
|
||||
|
||||
if not Started then
|
||||
MsgBox('Служба не запустилась — контроль сейчас не работает.'#13#10 +
|
||||
'Откройте «Службы» и посмотрите 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);
|
||||
@@ -239,6 +306,10 @@ begin
|
||||
if CurUninstallStep <> usUninstall then
|
||||
Exit;
|
||||
|
||||
// Порядок обратный установке: сначала гасим службу, потом возвращаем сеть —
|
||||
// работающая служба переставила бы настройку обратно на следующем такте.
|
||||
StopService();
|
||||
RestoreDns();
|
||||
RunSc('delete {#ServiceName}');
|
||||
RemoveFirewallRule();
|
||||
end;
|
||||
|
||||
+1001
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,646 @@
|
||||
//! Тесты 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"));
|
||||
}
|
||||
|
||||
/* ── сквозная проверка ─────────────────────────────────────── */
|
||||
|
||||
/// Адрес, которым отвечает поддельный сервер: по нему видно, что ответ пришёл
|
||||
/// сверху, а не собран здесь же.
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Поднимает резолвер на случайном порту и возвращает его адрес.
|
||||
async fn spawn_resolver(upstream: SocketAddr, blocked: &[&str]) -> (SocketAddr, watch::Sender<bool>) {
|
||||
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 (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)
|
||||
}
|
||||
|
||||
/// Спрашивает резолвер и возвращает ответ целиком.
|
||||
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
@@ -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;
|
||||
}
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
//! Настройка сети 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;\
|
||||
foreach($a in Get-NetAdapter){\
|
||||
try{Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('127.0.0.1');$n++}catch{};\
|
||||
try{Set-DnsClientServerAddress -InterfaceIndex $a.ifIndex -ServerAddresses ('::1')}catch{}\
|
||||
};\
|
||||
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;
|
||||
@@ -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);
|
||||
}
|
||||
+130
-4
@@ -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: [],
|
||||
});
|
||||
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,95 @@ 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 RandomKill = {
|
||||
emits: ['schedule'],
|
||||
setup(_, { emit }) {
|
||||
@@ -247,15 +354,16 @@ const EventLog = {
|
||||
/* ── корневой компонент ────────────────────────────────────── */
|
||||
|
||||
const App = {
|
||||
components: { UpdateBar, RulesPanel, RandomKill, ProcessTable, EventLog },
|
||||
components: { UpdateBar, RulesPanel, SitesPanel, 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 +384,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 +404,13 @@ 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 })" />
|
||||
<random-kill @schedule="scheduleRandom" />
|
||||
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
|
||||
<event-log :lines="lines" />`,
|
||||
|
||||
+5
-1
@@ -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
Reference in New Issue
Block a user