feat: initial commit

This commit is contained in:
2026-08-02 13:47:00 +07:00
commit 32d2553380
9 changed files with 3078 additions and 0 deletions
+475
View File
@@ -0,0 +1,475 @@
//! Родительский контроль для Windows.
//! Веб-интерфейс и API на http://127.0.0.1:8787
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::PathBuf,
sync::{Arc, OnceLock},
time::Duration,
};
use sysinfo::{Pid, System};
use tokio::sync::Mutex;
mod update;
const SCAN_INTERVAL: Duration = Duration::from_secs(2);
const LOG_LIMIT: usize = 500;
// фронт вшивается в exe на этапе компиляции
const INDEX_HTML: &str = include_str!("../static/index.html");
const APP_JS: &str = include_str!("../static/app.js");
/// Нельзя убивать никогда — иначе BSOD или сломанный рабочий стол.
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)
}
/// Правила храним в профиле пользователя, а не рядом с 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
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")));
match base {
Some(dir) => {
let dir = dir.join("SysHelper");
let _ = std::fs::create_dir_all(&dir);
dir.join("rules.json")
}
// без домашнего каталога остаётся только рабочая директория
None => PathBuf::from("rules.json"),
}
})
}
/* ── модель данных ─────────────────────────────────────────── */
#[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,
}
impl Default for Rules {
fn default() -> Self {
Self {
mode: Mode::Blacklist,
blocked: HashSet::new(),
allowed: HashSet::new(),
enforce: true,
}
}
}
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 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>,
}
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 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 {
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) {
loop {
tokio::time::sleep(SCAN_INTERVAL).await;
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) {
continue;
}
if rules.violates(&name) && proc_.kill() {
killed.push((name, pid_u32));
}
}
}
for (name, pid) in killed {
state
.log(format!("правило сработало: {name} (PID {pid}) остановлен"))
.await;
}
}
}
/* ── статика ───────────────────────────────────────────────── */
async fn index() -> Html<&'static str> {
Html(INDEX_HTML)
}
async fn app_js() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/javascript; charset=utf-8")],
APP_JS,
)
}
/* ── 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) && 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();
{
let mut rules = state.rules.lock().await;
*rules = new_rules;
rules.save();
}
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 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()) => {
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_spawn(Arc::clone(&state)));
Json(json!({ "ok": true }))
}
/* ── точка входа ───────────────────────────────────────────── */
#[tokio::main]
async fn main() {
let state: Shared = Arc::new(AppState {
rules: Mutex::new(Rules::load()),
sys: Mutex::new(System::new_all()),
log: Mutex::new(Vec::new()),
update: Mutex::new(update::UpdateStatus::new()),
});
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))
.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("/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))
.with_state(Arc::clone(&state));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8787")
.await
.expect("порт 8787 занят");
state.log("сервер запущен на http://127.0.0.1:8787").await;
state
.log(format!("правила: {}", rules_path().display()))
.await;
axum::serve(listener, app).await.unwrap();
}