5 Commits

Author SHA1 Message Date
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
robonen dfecd5dafb feat: bump syshelper version to 0.1.1 in Cargo.lock
CI / Windows x64 (push) Successful in 9m20s
2026-08-02 21:00:35 +07:00
robonen 6d01e5ccc2 feat: bump version to 0.1.1 in Cargo.toml
CI / Windows x64 (push) Has been cancelled
2026-08-02 20:51:08 +07:00
robonen 8da02e2392 feat: improve service start logic in installer to ensure proper error handling and feedback
CI / Windows x64 (push) Has been cancelled
2026-08-02 20:48:12 +07:00
12 changed files with 2852 additions and 21 deletions
+97
View File
@@ -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
View File
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]]
name = "syshelper"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"argon2",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "syshelper"
version = "0.1.0"
version = "0.3.0"
edition = "2021"
[dependencies]
+74 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+646
View File
@@ -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
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;
}
+444
View File
@@ -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;
+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);
}
+130 -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: [],
});
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
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