//! Родительский контроль для 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> = 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 = 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 { let text = std::fs::read_to_string(log_path()).unwrap_or_default(); let lines: Vec = 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, // всегда в нижнем регистре allowed: HashSet, enforce: bool, /// Домены, закрытые через свой DNS. Запись закрывает и поддомены. /// /// `serde(default)` здесь обязателен: без него правила, записанные прежней /// версией, перестали бы читаться, а `Rules::load` молча подставил бы /// пустые — то есть обновление стёрло бы все настройки родителя. #[serde(default)] blocked_sites: HashSet, /// Включена ли блокировка сайтов целиком. /// /// По умолчанию выключена, и это осознанно: обновление приходит само, без /// участия человека, и не должно молча переставлять настройки сети на /// машине, где о новой возможности ещё не знают. Включает родитель в панели. #[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, memory_mb: u64, blocked: bool, } struct AppState { rules: Mutex, sys: Mutex, log: Mutex>, update: Mutex, auth: auth::Auth, dns: dns::Dns, } type Shared = Arc; /// Передаёт резолверу то, что должно быть закрыто прямо сейчас. /// /// Снятая галочка «применять правила» обязана выключать и блокировку сайтов: /// иначе панель показывала бы одно, а резолвер делал другое. Список правил при /// этом сохраняется — выключение не должно стирать то, что родитель набирал. 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) { 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 { 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) { 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) { // Каждый вызов 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> = 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(work: F) -> Option 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) -> Json> { let rules = state.rules.lock().await.clone(); let mut sys = state.sys.lock().await; sys.refresh_processes(); let mut list: Vec = 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) -> Json { Json(state.rules.lock().await.clone()) } async fn set_rules( State(state): State, Json(mut new_rules): Json, ) -> Json { 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, Json(req): Json, ) -> Json { 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, Json(req): Json, ) -> Json { 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, Json(req): Json, ) -> Json { 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, Json(req): Json, ) -> Json { 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, Json(req): Json, ) -> Json { // Убираем и по нормализованному имени, и по введённому: в списке могла // остаться запись, добавленная до того, как появилась нормализация. 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) -> Json { Json(state.dns.status()) } async fn kill_now( State(state): State, Json(req): Json, ) -> Json { 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, Json(req): Json) -> Json { 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, Json(req): Json, ) -> Json { 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) -> Json> { Json(state.log.lock().await.clone()) } async fn update_status(State(state): State) -> Json { Json(state.update.lock().await.clone()) } async fn update_check(State(state): State) -> Json { // не блокируем ответ: проверка и запуск установки идут в фоне 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 = 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, ) -> Option { 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) { 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; }