feat: implement Windows service for parental control application

This commit is contained in:
2026-08-02 16:24:53 +07:00
parent 32d2553380
commit 229a02b543
10 changed files with 1111 additions and 272 deletions
+260 -38
View File
@@ -1,5 +1,13 @@
//! Родительский контроль для Windows.
//! Родительский контроль для Windows. Работает службой от SYSTEM.
//! Веб-интерфейс и API на http://127.0.0.1:8787
//!
//! Служба, а не обычная программа, по одной причине: контроль должен пережить
//! того, кого он ограничивает. Процесс от пользователя снимался бы диспетчером
//! задач, а его правила правились бы блокнотом.
// GUI-подсистема: своего окна у программы нет. Панель — в браузере, служба
// живёт в сеансе 0, события пишутся в файл журнала.
#![cfg_attr(windows, windows_subsystem = "windows")]
use axum::{
extract::State,
@@ -13,23 +21,35 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
collections::HashSet,
path::PathBuf,
path::{Path, PathBuf},
sync::{Arc, OnceLock},
time::Duration,
};
use sysinfo::{Pid, System};
use tokio::sync::Mutex;
mod auth;
#[cfg(windows)]
mod service;
mod update;
const ADDR: &str = "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");
/// Нельзя убивать никогда — иначе BSOD или сломанный рабочий стол.
/// Список имён — только первый рубеж, основной барьер в `is_system_path`.
const PROTECTED: &[&str] = &[
"system", "system idle process", "registry", "memory compression",
"smss.exe", "csrss.exe", "wininit.exe", "winlogon.exe", "services.exe",
@@ -44,27 +64,120 @@ fn is_protected(name_lower: &str) -> bool {
PROTECTED.contains(&name_lower)
}
/// Правила храним в профиле пользователя, а не рядом с exe: установленная
/// программа лежит в Program Files, куда без прав администратора не записать.
fn rules_path() -> &'static PathBuf {
static PATH: OnceLock<PathBuf> = OnceLock::new();
PATH.get_or_init(|| {
let base = std::env::var_os("APPDATA") // Windows
/// Второй, главный барьер: всё из каталога 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(".config")));
.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.join("rules.json")
dir
}
// без домашнего каталога остаётся только рабочая директория
None => PathBuf::from("rules.json"),
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() {
let url = format!("http://{ADDR}");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// пустой заголовок обязателен: иначе start примет URL за имя окна
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "", &url])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(not(windows))]
let _ = std::process::Command::new("open").arg(&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)]
@@ -129,18 +242,15 @@ struct AppState {
sys: Mutex<System>,
log: Mutex<Vec<String>>,
update: Mutex<update::UpdateStatus>,
auth: auth::Auth,
}
type Shared = Arc<AppState>;
impl AppState {
async fn log(&self, msg: impl Into<String>) {
let line = format!(
"[{}] {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
msg.into()
);
println!("{line}");
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 {
@@ -167,7 +277,7 @@ async fn kill_by_name(state: &Shared, name: &str) -> Vec<u32> {
sys.refresh_processes();
for (pid, proc_) in sys.processes() {
let pid_u32 = pid.as_u32();
if pid_u32 == me {
if pid_u32 == me || is_system_path(proc_.exe()) {
continue;
}
if proc_.name().to_lowercase() == target && proc_.kill() {
@@ -183,9 +293,14 @@ async fn kill_by_name(state: &Shared, name: &str) -> Vec<u32> {
}
/// Раз в SCAN_INTERVAL применяет правила ко всем процессам.
async fn watchdog(state: Shared) {
async fn watchdog(state: Shared, mut shutdown: tokio::sync::watch::Receiver<bool>) {
loop {
tokio::time::sleep(SCAN_INTERVAL).await;
// При остановке службы уходим сразу, а не досыпаем интервал: SCM ждёт
// ответа и считает зависшей ту службу, что тянет с остановкой.
tokio::select! {
_ = tokio::time::sleep(SCAN_INTERVAL) => {}
_ = shutdown.changed() => return,
}
let rules = state.rules.lock().await.clone();
if !rules.enforce {
@@ -205,7 +320,7 @@ async fn watchdog(state: Shared) {
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) {
if pid_u32 == me || is_protected(&name) || is_system_path(proc_.exe()) {
continue;
}
if rules.violates(&name) && proc_.kill() {
@@ -250,7 +365,10 @@ async fn list_processes(State(state): State<Shared>) -> Json<Vec<ProcInfo>> {
let lower = name.to_lowercase();
ProcInfo {
pid: pid.as_u32(),
blocked: !is_protected(&lower) && rules.violates(&lower),
// помечаем только то, что действительно будет остановлено
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,
@@ -356,7 +474,7 @@ async fn kill_pid(State(state): State<Shared>, Json(req): Json<PidReq>) -> Json<
sys.refresh_processes();
let result = match sys.process(Pid::from_u32(req.pid)) {
Some(p) if is_protected(&p.name().to_lowercase()) => {
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() }),
@@ -428,25 +546,103 @@ async fn update_status(State(state): State<Shared>) -> Json<update::UpdateStatus
}
async fn update_check(State(state): State<Shared>) -> Json<serde_json::Value> {
// не блокируем ответ: проверка и возможная установка идут в фоне
tokio::spawn(update::check_spawn(Arc::clone(&state)));
// не блокируем ответ: проверка и запуск установки идут в фоне
tokio::spawn(update::check(Arc::clone(&state), true));
Json(json!({ "ok": true }))
}
/* ── точка входа ───────────────────────────────────────────── */
#[tokio::main]
async fn main() {
// 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("--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(ADDR).await {
Ok(listener) => return Some(listener),
Err(e) if !complained => {
complained = true; // в журнал раз, а не каждые полминуты
state
.log(format!("{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 state: Shared = Arc::new(AppState {
rules: Mutex::new(Rules::load()),
sys: Mutex::new(System::new_all()),
log: Mutex::new(Vec::new()),
// журнал переживает перезапуск службы — иначе после обновления панель
// показывала бы пустоту и было бы неясно, что вообще произошло
log: Mutex::new(load_log_tail()),
update: Mutex::new(update::UpdateStatus::new()),
auth: auth::Auth::load(),
});
tokio::spawn(watchdog(Arc::clone(&state)));
tokio::spawn(update::run(Arc::clone(&state)));
let app = Router::new()
.route("/", get(index))
.route("/app.js", get(app_js))
@@ -461,15 +657,41 @@ async fn main() {
.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));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8787")
.await
.expect("порт 8787 занят");
// Защита не должна зависеть от панели. Сторожевой цикл поднимаем до того,
// как занимать порт: если 8787 занят чужой программой, правила всё равно
// применяются, а панель дождётся освобождения порта.
tokio::spawn(watchdog(Arc::clone(&state), shutdown.clone()));
tokio::spawn(update::run(Arc::clone(&state)));
state.log("сервер запущен на http://127.0.0.1:8787").await;
state.log("служба запущена").await;
state
.log(format!("правила: {}", rules_path().display()))
.log(format!("данные: {}", data_dir().display()))
.await;
axum::serve(listener, app).await.unwrap();
let Some(listener) = bind_panel(&state, shutdown.clone()).await else {
return; // службу остановили, пока ждали порт
};
state.log(format!("панель на http://{ADDR}")).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()));
}
state.log("служба остановлена").await;
}