feat: add custom dns resolver
CI / Windows x64 (push) Successful in 9m31s

This commit is contained in:
2026-08-09 15:47:38 +07:00
parent ccaf521da4
commit 0223dd817b
12 changed files with 2780 additions and 10 deletions
+334 -3
View File
@@ -29,6 +29,8 @@ use sysinfo::{Pid, System};
use tokio::sync::Mutex;
mod auth;
mod dns;
mod netcfg;
#[cfg(windows)]
mod service;
mod update;
@@ -52,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`.
@@ -196,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 {
@@ -205,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,
}
}
}
@@ -223,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),
@@ -246,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());
@@ -340,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> {
@@ -347,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,
)
}
@@ -398,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 }))
@@ -459,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>,
@@ -579,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) {
@@ -636,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))
@@ -675,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
@@ -682,7 +1006,11 @@ 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!("панель на {PANEL_URL} и на порту 8787 в локальной сети"))
@@ -698,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;
}