This commit is contained in:
+334
-3
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user