feat: implement Windows service for parental control application
This commit is contained in:
+260
-38
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user