Files
syshelper/src/main.rs
T
robonen 0223dd817b
CI / Windows x64 (push) Successful in 9m31s
feat: add custom dns resolver
2026-08-09 15:47:38 +07:00

1033 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Родительский контроль для 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;
}