1033 lines
45 KiB
Rust
1033 lines
45 KiB
Rust
//! Родительский контроль для Windows. Работает службой от SYSTEM.
|
||
//! Веб-интерфейс и API на http://127.0.0.1:8787
|
||
//!
|
||
//! Служба, а не обычная программа, по одной причине: контроль должен пережить
|
||
//! того, кого он ограничивает. Процесс от пользователя снимался бы диспетчером
|
||
//! задач, а его правила правились бы блокнотом.
|
||
|
||
// GUI-подсистема: своего окна у программы нет. Панель — в браузере, служба
|
||
// живёт в сеансе 0, события пишутся в файл журнала.
|
||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||
|
||
use axum::{
|
||
extract::State,
|
||
http::header,
|
||
response::{Html, IntoResponse},
|
||
routing::{get, post},
|
||
Json, Router,
|
||
};
|
||
use rand::Rng;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::json;
|
||
use std::{
|
||
collections::HashSet,
|
||
path::{Path, PathBuf},
|
||
sync::{Arc, OnceLock},
|
||
time::Duration,
|
||
};
|
||
use sysinfo::{Pid, System};
|
||
use tokio::sync::Mutex;
|
||
|
||
mod auth;
|
||
mod dns;
|
||
mod netcfg;
|
||
#[cfg(windows)]
|
||
mod service;
|
||
mod update;
|
||
|
||
/// Слушаем все интерфейсы, чтобы панель открывалась и с телефона или другого
|
||
/// компьютера в домашней сети. Правило брандмауэра ставит установщик, и только
|
||
/// для частных сетей — в публичной панель наружу смотреть не должна.
|
||
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;
|
||
const SCAN_INTERVAL: Duration = Duration::from_secs(2);
|
||
/// Как часто пробовать занять порт панели, если он занят.
|
||
const BIND_RETRY: Duration = Duration::from_secs(30);
|
||
const LOG_LIMIT: usize = 500;
|
||
/// Порог, после которого файл журнала перезаписывается с нуля.
|
||
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`.
|
||
const PROTECTED: &[&str] = &[
|
||
"system", "system idle process", "registry", "memory compression",
|
||
"smss.exe", "csrss.exe", "wininit.exe", "winlogon.exe", "services.exe",
|
||
"lsass.exe", "lsaiso.exe", "svchost.exe", "dwm.exe", "explorer.exe",
|
||
"fontdrvhost.exe", "sihost.exe", "ctfmon.exe", "taskhostw.exe",
|
||
"runtimebroker.exe", "conhost.exe", "spoolsv.exe", "audiodg.exe",
|
||
"searchindexer.exe", "searchhost.exe", "shellexperiencehost.exe",
|
||
"startmenuexperiencehost.exe", "wudfhost.exe", "syshelper.exe",
|
||
];
|
||
|
||
fn is_protected(name_lower: &str) -> bool {
|
||
PROTECTED.contains(&name_lower)
|
||
}
|
||
|
||
/// Второй, главный барьер: всё из каталога Windows не трогаем вовсе.
|
||
///
|
||
/// От пользователя программа системный процесс убить и не могла — не хватало
|
||
/// прав. От SYSTEM может, и цена ошибки — синий экран. Перечислить все
|
||
/// системные имена нереально, они меняются от версии к версии, поэтому
|
||
/// решает расположение файла. Процесс, чей путь прочитать не удалось, тоже
|
||
/// считаем системным: как раз защищённые процессы путь и скрывают.
|
||
fn is_system_path(exe: Option<&Path>) -> bool {
|
||
let Some(exe) = exe else {
|
||
return true;
|
||
};
|
||
windows_dir().is_some_and(|root| exe.starts_with(root))
|
||
}
|
||
|
||
fn windows_dir() -> Option<&'static PathBuf> {
|
||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||
DIR.get_or_init(|| std::env::var_os("SystemRoot").map(PathBuf::from))
|
||
.as_ref()
|
||
}
|
||
|
||
/// Данные лежат в ProgramData, а не в профиле пользователя: писать туда может
|
||
/// только служба (SYSTEM) и администраторы — права выставляет установщик.
|
||
/// Иначе ограниченный пользователь просто отредактировал бы rules.json.
|
||
fn data_dir() -> &'static PathBuf {
|
||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||
DIR.get_or_init(|| {
|
||
let base = std::env::var_os("ProgramData") // Windows
|
||
.map(PathBuf::from)
|
||
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")));
|
||
|
||
match base {
|
||
Some(dir) => {
|
||
let dir = dir.join("SysHelper");
|
||
let _ = std::fs::create_dir_all(&dir);
|
||
dir
|
||
}
|
||
None => PathBuf::from("."),
|
||
}
|
||
})
|
||
}
|
||
|
||
fn rules_path() -> PathBuf {
|
||
data_dir().join("rules.json")
|
||
}
|
||
|
||
/// Окна нет, поэтому события пишем ещё и в файл — иначе их не увидеть.
|
||
fn log_path() -> PathBuf {
|
||
data_dir().join("syshelper.log")
|
||
}
|
||
|
||
fn auth_path() -> PathBuf {
|
||
data_dir().join("auth.hash")
|
||
}
|
||
|
||
fn stamp() -> String {
|
||
chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
||
}
|
||
|
||
/// Открывает панель в браузере. Своего окна у программы нет, поэтому запуск
|
||
/// ярлыком без этого выглядел бы как «ничего не произошло».
|
||
fn open_panel() {
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::process::CommandExt;
|
||
// пустой заголовок обязателен: иначе start примет URL за имя окна
|
||
let _ = std::process::Command::new("cmd")
|
||
.args(["/C", "start", "", PANEL_URL])
|
||
.creation_flags(CREATE_NO_WINDOW)
|
||
.spawn();
|
||
}
|
||
#[cfg(not(windows))]
|
||
let _ = std::process::Command::new("open").arg(PANEL_URL).spawn();
|
||
}
|
||
|
||
/// Окна нет, поэтому паника иначе прошла бы совершенно незаметно: процесс
|
||
/// просто исчез бы вместе с защитой.
|
||
fn log_panics() {
|
||
let previous = std::panic::take_hook();
|
||
std::panic::set_hook(Box::new(move |info| {
|
||
append_log_file(&format!("[{}] АВАРИЯ: {info}", stamp()));
|
||
previous(info);
|
||
}));
|
||
}
|
||
|
||
/// Хвост файла журнала — чтобы панель показывала историю и после перезапуска.
|
||
fn load_log_tail() -> Vec<String> {
|
||
let text = std::fs::read_to_string(log_path()).unwrap_or_default();
|
||
let lines: Vec<String> = text.lines().map(str::to_string).collect();
|
||
let skip = lines.len().saturating_sub(LOG_LIMIT);
|
||
lines.into_iter().skip(skip).collect()
|
||
}
|
||
|
||
/// Дописывает строку в журнал. Файл переживает перезапуски, поэтому при
|
||
/// разрастании начинаем его заново — журнал справочный, не аудит.
|
||
fn append_log_file(line: &str) {
|
||
use std::io::Write;
|
||
|
||
let path = log_path();
|
||
let too_big = std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_FILE_LIMIT);
|
||
|
||
let opened = std::fs::OpenOptions::new()
|
||
.create(true)
|
||
.append(!too_big)
|
||
.write(true)
|
||
.truncate(too_big)
|
||
.open(&path);
|
||
|
||
if let Ok(mut file) = opened {
|
||
let _ = writeln!(file, "{line}");
|
||
}
|
||
}
|
||
|
||
/* ── модель данных ─────────────────────────────────────────── */
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
enum Mode {
|
||
Blacklist,
|
||
Whitelist,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
struct Rules {
|
||
mode: Mode,
|
||
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 {
|
||
fn default() -> Self {
|
||
Self {
|
||
mode: Mode::Blacklist,
|
||
blocked: HashSet::new(),
|
||
allowed: HashSet::new(),
|
||
enforce: true,
|
||
blocked_sites: HashSet::new(),
|
||
sites_enforce: false,
|
||
dns_lockdown: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Rules {
|
||
fn load() -> Self {
|
||
std::fs::read_to_string(rules_path())
|
||
.ok()
|
||
.and_then(|s| serde_json::from_str(&s).ok())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn save(&self) {
|
||
if let Ok(s) = serde_json::to_string_pretty(self) {
|
||
let _ = std::fs::write(rules_path(), s);
|
||
}
|
||
}
|
||
|
||
/// Работает ли сейчас блокировка сайтов. Общая галочка «применять правила»
|
||
/// старше частной: выключив её, родитель ожидает, что выключилось всё.
|
||
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),
|
||
Mode::Whitelist => !self.allowed.contains(name_lower),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct ProcInfo {
|
||
pid: u32,
|
||
name: String,
|
||
exe: Option<String>,
|
||
memory_mb: u64,
|
||
blocked: bool,
|
||
}
|
||
|
||
struct AppState {
|
||
rules: Mutex<Rules>,
|
||
sys: Mutex<System>,
|
||
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());
|
||
append_log_file(&line);
|
||
let mut log = self.log.lock().await;
|
||
log.push(line);
|
||
if log.len() > LOG_LIMIT {
|
||
let extra = log.len() - LOG_LIMIT;
|
||
log.drain(0..extra);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── завершение процессов ──────────────────────────────────── */
|
||
|
||
/// Убивает все процессы с данным именем, возвращает убитые PID.
|
||
async fn kill_by_name(state: &Shared, name: &str) -> Vec<u32> {
|
||
let target = name.trim().to_lowercase();
|
||
if is_protected(&target) {
|
||
return Vec::new();
|
||
}
|
||
|
||
let me = std::process::id();
|
||
let mut killed = Vec::new();
|
||
|
||
{
|
||
let mut sys = state.sys.lock().await;
|
||
sys.refresh_processes();
|
||
for (pid, proc_) in sys.processes() {
|
||
let pid_u32 = pid.as_u32();
|
||
if pid_u32 == me || is_system_path(proc_.exe()) {
|
||
continue;
|
||
}
|
||
if proc_.name().to_lowercase() == target && proc_.kill() {
|
||
killed.push(pid_u32);
|
||
}
|
||
}
|
||
} // гвард дропаем до await ниже
|
||
|
||
for pid in &killed {
|
||
state.log(format!("убит {target} (PID {pid})")).await;
|
||
}
|
||
killed
|
||
}
|
||
|
||
/// Раз в SCAN_INTERVAL применяет правила ко всем процессам.
|
||
async fn watchdog(state: Shared, mut shutdown: tokio::sync::watch::Receiver<bool>) {
|
||
loop {
|
||
// При остановке службы уходим сразу, а не досыпаем интервал: SCM ждёт
|
||
// ответа и считает зависшей ту службу, что тянет с остановкой.
|
||
tokio::select! {
|
||
_ = tokio::time::sleep(SCAN_INTERVAL) => {}
|
||
_ = shutdown.changed() => return,
|
||
}
|
||
|
||
let rules = state.rules.lock().await.clone();
|
||
if !rules.enforce {
|
||
continue;
|
||
}
|
||
// whitelist с пустым списком вынес бы всю систему
|
||
if rules.mode == Mode::Whitelist && rules.allowed.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let me = std::process::id();
|
||
let mut killed: Vec<(String, u32)> = Vec::new();
|
||
|
||
{
|
||
let mut sys = state.sys.lock().await;
|
||
sys.refresh_processes();
|
||
for (pid, proc_) in sys.processes() {
|
||
let pid_u32 = pid.as_u32();
|
||
let name = proc_.name().to_lowercase();
|
||
if pid_u32 == me || is_protected(&name) || is_system_path(proc_.exe()) {
|
||
continue;
|
||
}
|
||
if rules.violates(&name) && proc_.kill() {
|
||
killed.push((name, pid_u32));
|
||
}
|
||
}
|
||
}
|
||
|
||
for (name, pid) in killed {
|
||
state
|
||
.log(format!("правило сработало: {name} (PID {pid}) остановлен"))
|
||
.await;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── согласование настроек сети ────────────────────────────── */
|
||
|
||
/// Как часто сверять системную настройку с правилами.
|
||
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> {
|
||
Html(INDEX_HTML)
|
||
}
|
||
|
||
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")],
|
||
body,
|
||
)
|
||
}
|
||
|
||
/* ── HTTP-обработчики ──────────────────────────────────────── */
|
||
|
||
async fn list_processes(State(state): State<Shared>) -> Json<Vec<ProcInfo>> {
|
||
let rules = state.rules.lock().await.clone();
|
||
let mut sys = state.sys.lock().await;
|
||
sys.refresh_processes();
|
||
|
||
let mut list: Vec<ProcInfo> = sys
|
||
.processes()
|
||
.iter()
|
||
.map(|(pid, p)| {
|
||
let name = p.name().to_string();
|
||
let lower = name.to_lowercase();
|
||
ProcInfo {
|
||
pid: pid.as_u32(),
|
||
// помечаем только то, что действительно будет остановлено
|
||
blocked: !is_protected(&lower)
|
||
&& !is_system_path(p.exe())
|
||
&& rules.violates(&lower),
|
||
name,
|
||
exe: p.exe().map(|path| path.display().to_string()),
|
||
memory_mb: p.memory() / 1024 / 1024,
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
list.sort_by(|a, b| {
|
||
a.name
|
||
.to_lowercase()
|
||
.cmp(&b.name.to_lowercase())
|
||
.then(a.pid.cmp(&b.pid))
|
||
});
|
||
Json(list)
|
||
}
|
||
|
||
async fn get_rules(State(state): State<Shared>) -> Json<Rules> {
|
||
Json(state.rules.lock().await.clone())
|
||
}
|
||
|
||
async fn set_rules(
|
||
State(state): State<Shared>,
|
||
Json(mut new_rules): Json<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 }))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct NameReq {
|
||
name: String,
|
||
}
|
||
|
||
async fn rule_block(
|
||
State(state): State<Shared>,
|
||
Json(req): Json<NameReq>,
|
||
) -> Json<serde_json::Value> {
|
||
let name = req.name.trim().to_lowercase();
|
||
if is_protected(&name) {
|
||
return Json(json!({ "ok": false, "error": "системный процесс" }));
|
||
}
|
||
{
|
||
let mut rules = state.rules.lock().await;
|
||
rules.blocked.insert(name.clone());
|
||
rules.allowed.remove(&name);
|
||
rules.save();
|
||
}
|
||
state.log(format!("в чёрный список: {name}")).await;
|
||
Json(json!({ "ok": true, "name": name }))
|
||
}
|
||
|
||
async fn rule_allow(
|
||
State(state): State<Shared>,
|
||
Json(req): Json<NameReq>,
|
||
) -> Json<serde_json::Value> {
|
||
let name = req.name.trim().to_lowercase();
|
||
{
|
||
let mut rules = state.rules.lock().await;
|
||
rules.allowed.insert(name.clone());
|
||
rules.blocked.remove(&name);
|
||
rules.save();
|
||
}
|
||
state.log(format!("в белый список: {name}")).await;
|
||
Json(json!({ "ok": true, "name": name }))
|
||
}
|
||
|
||
async fn rule_remove(
|
||
State(state): State<Shared>,
|
||
Json(req): Json<NameReq>,
|
||
) -> Json<serde_json::Value> {
|
||
let name = req.name.trim().to_lowercase();
|
||
{
|
||
let mut rules = state.rules.lock().await;
|
||
rules.blocked.remove(&name);
|
||
rules.allowed.remove(&name);
|
||
rules.save();
|
||
}
|
||
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>,
|
||
) -> Json<serde_json::Value> {
|
||
let killed = kill_by_name(&state, &req.name).await;
|
||
Json(json!({ "ok": !killed.is_empty(), "killed": killed }))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct PidReq {
|
||
pid: u32,
|
||
}
|
||
|
||
async fn kill_pid(State(state): State<Shared>, Json(req): Json<PidReq>) -> Json<serde_json::Value> {
|
||
let mut sys = state.sys.lock().await;
|
||
sys.refresh_processes();
|
||
|
||
let result = match sys.process(Pid::from_u32(req.pid)) {
|
||
Some(p) if is_protected(&p.name().to_lowercase()) || is_system_path(p.exe()) => {
|
||
json!({ "ok": false, "error": "системный процесс" })
|
||
}
|
||
Some(p) => json!({ "ok": p.kill(), "name": p.name() }),
|
||
None => json!({ "ok": false, "error": "процесс не найден" }),
|
||
};
|
||
drop(sys);
|
||
|
||
if result["ok"] == json!(true) {
|
||
state.log(format!("ручное завершение PID {}", req.pid)).await;
|
||
}
|
||
Json(result)
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct RandomKillReq {
|
||
name: String,
|
||
#[serde(default = "default_min")]
|
||
min_secs: u64,
|
||
#[serde(default = "default_max")]
|
||
max_secs: u64,
|
||
}
|
||
fn default_min() -> u64 {
|
||
60
|
||
}
|
||
fn default_max() -> u64 {
|
||
1800
|
||
}
|
||
|
||
/// Завершение в случайный момент внутри окна [min, max].
|
||
async fn kill_random(
|
||
State(state): State<Shared>,
|
||
Json(req): Json<RandomKillReq>,
|
||
) -> Json<serde_json::Value> {
|
||
let min = req.min_secs;
|
||
let max = req.max_secs.max(min + 1);
|
||
|
||
// ThreadRng не Send — держать его через .await компилятор не даст
|
||
let delay = {
|
||
let mut rng = rand::thread_rng();
|
||
rng.gen_range(min..=max)
|
||
};
|
||
|
||
let name = req.name.clone();
|
||
let st = Arc::clone(&state);
|
||
tokio::spawn(async move {
|
||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||
let killed = kill_by_name(&st, &name).await;
|
||
if killed.is_empty() {
|
||
st.log(format!("случайное завершение: {name} уже не запущен"))
|
||
.await;
|
||
}
|
||
});
|
||
|
||
state
|
||
.log(format!(
|
||
"запланировано случайное завершение {} через {delay} с",
|
||
req.name
|
||
))
|
||
.await;
|
||
Json(json!({ "ok": true, "name": req.name, "delay_secs": delay }))
|
||
}
|
||
|
||
async fn get_log(State(state): State<Shared>) -> Json<Vec<String>> {
|
||
Json(state.log.lock().await.clone())
|
||
}
|
||
|
||
async fn update_status(State(state): State<Shared>) -> Json<update::UpdateStatus> {
|
||
Json(state.update.lock().await.clone())
|
||
}
|
||
|
||
async fn update_check(State(state): State<Shared>) -> Json<serde_json::Value> {
|
||
// не блокируем ответ: проверка и запуск установки идут в фоне
|
||
tokio::spawn(update::check(Arc::clone(&state), true));
|
||
Json(json!({ "ok": true }))
|
||
}
|
||
|
||
/* ── точка входа ───────────────────────────────────────────── */
|
||
|
||
// main синхронный намеренно. В режиме службы диспетчер SCM блокирует поток и
|
||
// вызывает точку входа в своём — рантайм там создаёт service.rs. Пометь main
|
||
// как #[tokio::main], и рантайма стало бы два: один впустую держал бы рабочие
|
||
// потоки всю жизнь службы.
|
||
fn main() {
|
||
log_panics();
|
||
|
||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||
|
||
match args.first().map(String::as_str) {
|
||
// Запуск диспетчером служб — основной режим.
|
||
Some("--service") => {
|
||
#[cfg(windows)]
|
||
service::start();
|
||
}
|
||
// Отладочный запуск без SCM: панель работает, события идут в журнал.
|
||
Some("--foreground") => {
|
||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||
match tokio::runtime::Runtime::new() {
|
||
Ok(runtime) => runtime.block_on(serve(rx)),
|
||
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) {
|
||
Some(path) => set_password_from_file(Path::new(path)),
|
||
None => append_log_file(&format!("[{}] --set-password-file без пути", stamp())),
|
||
},
|
||
// Без аргументов программу запустили ярлыком — показываем панель.
|
||
_ => open_panel(),
|
||
}
|
||
}
|
||
|
||
/// Читает пароль из файла и сохраняет его хеш. Файл затираем сразу: он лежит
|
||
/// во временном каталоге установщика, но пароль в открытом виде там не нужен.
|
||
fn set_password_from_file(path: &Path) {
|
||
let raw = std::fs::read_to_string(path).unwrap_or_default();
|
||
// BOM пробелом не считается и молча стал бы частью пароля — тогда хеш
|
||
// не совпал бы с тем, что человек потом вводит в панели.
|
||
let password = raw.trim_start_matches('\u{feff}');
|
||
let result = auth::set_password(password);
|
||
let _ = std::fs::remove_file(path);
|
||
|
||
let note = match result {
|
||
Ok(()) => "пароль панели установлен".to_string(),
|
||
Err(e) => format!("не удалось установить пароль: {e}"),
|
||
};
|
||
append_log_file(&format!("[{}] {note}", stamp()));
|
||
}
|
||
|
||
/// Занимает порт панели, повторяя попытки. Возвращает None, если службу
|
||
/// остановили раньше, чем порт освободился.
|
||
async fn bind_panel(
|
||
state: &Shared,
|
||
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||
) -> Option<tokio::net::TcpListener> {
|
||
let mut complained = false;
|
||
loop {
|
||
match tokio::net::TcpListener::bind(BIND_ADDR).await {
|
||
Ok(listener) => return Some(listener),
|
||
Err(e) if !complained => {
|
||
complained = true; // в журнал раз, а не каждые полминуты
|
||
state
|
||
.log(format!("{BIND_ADDR} занят ({e}) — жду, правила при этом работают"))
|
||
.await;
|
||
}
|
||
Err(_) => {}
|
||
}
|
||
|
||
tokio::select! {
|
||
_ = tokio::time::sleep(BIND_RETRY) => {}
|
||
_ = shutdown.changed() => return None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Поднимает панель и фоновые задачи. Завершается по сигналу из `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),
|
||
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))
|
||
.route("/log", get(get_log))
|
||
.route("/update", get(update_status))
|
||
.route("/update/check", post(update_check))
|
||
.route("/logout", post(auth::logout))
|
||
// route_layer накрывает только объявленное выше. Страница входа идёт
|
||
// ниже и потому остаётся открытой — иначе войти было бы некуда.
|
||
.route_layer(axum::middleware::from_fn_with_state(
|
||
Arc::clone(&state),
|
||
auth::guard,
|
||
))
|
||
.route("/login", get(auth::login_form).post(auth::login))
|
||
.with_state(Arc::clone(&state));
|
||
|
||
// Защита не должна зависеть от панели. Сторожевой цикл поднимаем до того,
|
||
// как занимать порт: если 8787 занят чужой программой, правила всё равно
|
||
// применяются, а панель дождётся освобождения порта.
|
||
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
|
||
.log(format!("данные: {}", data_dir().display()))
|
||
.await;
|
||
|
||
let Some(listener) = bind_panel(&state, shutdown.clone()).await else {
|
||
// Службу остановили, пока ждали порт. Уйти молча нельзя: адаптеры уже
|
||
// могут смотреть на нас, и без возврата настроек машина осталась бы
|
||
// без разрешения имён.
|
||
let _ = dns_task.await;
|
||
return;
|
||
};
|
||
state
|
||
.log(format!("панель на {PANEL_URL} и на порту 8787 в локальной сети"))
|
||
.await;
|
||
|
||
let mut stop = shutdown.clone();
|
||
let result = axum::serve(listener, app)
|
||
.with_graceful_shutdown(async move {
|
||
let _ = stop.changed().await;
|
||
})
|
||
.await;
|
||
|
||
if let Err(e) = result {
|
||
append_log_file(&format!("[{}] сервер остановлен: {e}", stamp()));
|
||
}
|
||
// Ждём именно здесь: рантайм вот-вот остановится, и возврат настроек сети
|
||
// не успел бы доработать.
|
||
let _ = dns_task.await;
|
||
state.log("служба остановлена").await;
|
||
} |