feat: initial commit
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # publish release on tag
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Resolve version
|
||||||
|
id: meta
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||||
|
$version = $env:GITHUB_REF_NAME -replace '^v', ''
|
||||||
|
} else {
|
||||||
|
$version = (Select-String -Path Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' |
|
||||||
|
Select-Object -First 1).Matches[0].Groups[1].Value
|
||||||
|
}
|
||||||
|
"version=$version" >> $env:GITHUB_OUTPUT
|
||||||
|
Write-Host "version: $version"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release --locked
|
||||||
|
|
||||||
|
# Install unconditionally: the script uses ArchitecturesAllowed=x64compatible,
|
||||||
|
# which needs Inno Setup 6.3+. The version in the runner image is not guaranteed.
|
||||||
|
- name: Install Inno Setup
|
||||||
|
shell: pwsh
|
||||||
|
run: choco install innosetup --no-progress -y
|
||||||
|
|
||||||
|
- name: Build installer
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$iscc = Get-ChildItem -ErrorAction SilentlyContinue -Path @(
|
||||||
|
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
||||||
|
"${env:ProgramFiles}\Inno Setup 6\ISCC.exe"
|
||||||
|
) | Select-Object -First 1
|
||||||
|
if (-not $iscc) { throw "ISCC.exe not found" }
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||||
|
Copy-Item target\release\syshelper.exe dist\
|
||||||
|
|
||||||
|
& $iscc.FullName /DAppVersion=${{ steps.meta.outputs.version }} installer\setup.iss
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "ISCC exited with $LASTEXITCODE" }
|
||||||
|
|
||||||
|
Get-ChildItem dist | Format-Table Name, Length
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: syshelper-${{ steps.meta.outputs.version }}-windows-x64
|
||||||
|
path: dist/*.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Publish release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
$files = (Get-ChildItem dist\*.exe).FullName
|
||||||
|
gh release create $env:GITHUB_REF_NAME $files --generate-notes
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# локальное состояние, а не исходники
|
||||||
|
rules.json
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
Generated
+1747
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "syshelper"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.7"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sysinfo = "0.30"
|
||||||
|
rand = "0.8"
|
||||||
|
chrono = "0.4"
|
||||||
|
semver = "1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
; Установщик «System Helper» для Windows x64.
|
||||||
|
; Сборка: ISCC.exe /DAppVersion=0.1.0 installer\setup.iss
|
||||||
|
; Результат кладётся в dist\syshelper-<версия>-setup.exe
|
||||||
|
|
||||||
|
#ifndef AppVersion
|
||||||
|
#define AppVersion "0.0.0-dev"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef ExeSource
|
||||||
|
#define ExeSource "..\target\release\syshelper.exe"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define AppName "System Helper"
|
||||||
|
#define AppExeName "syshelper.exe"
|
||||||
|
#define AppUrl "http://127.0.0.1:8787"
|
||||||
|
; Имя задачи автообновления — должно совпадать с TASK_NAME в src/update.rs.
|
||||||
|
#define TaskName "SysHelperUpdate"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; AppId определяет программу при обновлении и удалении — не менять между версиями.
|
||||||
|
AppId={{B3F1C0A2-7E5D-4A19-9C3B-2D8E6F4A1B57}
|
||||||
|
AppName={#AppName}
|
||||||
|
AppVersion={#AppVersion}
|
||||||
|
AppVerName={#AppName} {#AppVersion}
|
||||||
|
DefaultDirName={autopf}\SysHelper
|
||||||
|
DefaultGroupName={#AppName}
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
UninstallDisplayName={#AppName}
|
||||||
|
UninstallDisplayIcon={app}\{#AppExeName}
|
||||||
|
|
||||||
|
OutputDir=..\dist
|
||||||
|
OutputBaseFilename=syshelper-{#AppVersion}-setup
|
||||||
|
Compression=lzma2/max
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
|
||||||
|
; Ставим в 64-битный Program Files; программа собрана только под x64.
|
||||||
|
ArchitecturesAllowed=x64compatible
|
||||||
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
|
||||||
|
; Program Files и автозапуск через HKLM требуют администратора.
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
CloseApplications=yes
|
||||||
|
RestartApplications=no
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||||
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "autostart"; \
|
||||||
|
Description: "Запускать при входе в систему"; \
|
||||||
|
GroupDescription: "Дополнительно:"
|
||||||
|
Name: "desktopicon"; \
|
||||||
|
Description: "{cm:CreateDesktopIcon}"; \
|
||||||
|
GroupDescription: "Дополнительно:"; \
|
||||||
|
Flags: unchecked
|
||||||
|
|
||||||
|
[Dirs]
|
||||||
|
; Сюда работающая от пользователя программа кладёт скачанный установщик, а
|
||||||
|
; задача Планировщика (от SYSTEM) забирает его. Нужен доступ на запись обоим.
|
||||||
|
Name: "{commonappdata}\SysHelper\update"; Permissions: users-modify
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "{#ExeSource}"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
|
||||||
|
Name: "{group}\Веб-панель"; Filename: "{#AppUrl}"
|
||||||
|
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Registry]
|
||||||
|
; Автозапуск для всех пользователей. Запускается без повышения прав —
|
||||||
|
; этого хватает, чтобы завершать процессы того же пользователя.
|
||||||
|
Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \
|
||||||
|
ValueType: string; ValueName: "SysHelper"; \
|
||||||
|
ValueData: """{app}\{#AppExeName}"""; \
|
||||||
|
Flags: uninsdeletevalue; Tasks: autostart
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
; Задача автообновления: программа скачивает свежий установщик и просит
|
||||||
|
; Планировщик запустить его от SYSTEM с наивысшими правами — так обновление
|
||||||
|
; ставится тихо, без запроса UAC. Своего расписания у задачи нет (SC ONCE в
|
||||||
|
; прошлом), её лишь запускают по требованию из программы.
|
||||||
|
Filename: "{sys}\schtasks.exe"; \
|
||||||
|
Parameters: "/Create /F /RU SYSTEM /RL HIGHEST /SC ONCE /SD 01/01/2000 /ST 00:00 /TN ""{#TaskName}"" /TR ""{commonappdata}\SysHelper\update\setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NOCANCEL"""; \
|
||||||
|
Flags: runhidden; StatusMsg: "Настройка автообновления..."
|
||||||
|
Filename: "{app}\{#AppExeName}"; \
|
||||||
|
Description: "Запустить {#AppName}"; \
|
||||||
|
Flags: nowait postinstall skipifsilent
|
||||||
|
Filename: "{#AppUrl}"; \
|
||||||
|
Description: "Открыть веб-панель"; \
|
||||||
|
Flags: shellexec nowait postinstall skipifsilent
|
||||||
|
|
||||||
|
[UninstallRun]
|
||||||
|
Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /F /TN ""{#TaskName}"""; \
|
||||||
|
Flags: runhidden; RunOnceId: "DelUpdateTask"
|
||||||
|
Filename: "{sys}\taskkill.exe"; Parameters: "/f /im {#AppExeName}"; \
|
||||||
|
Flags: runhidden; RunOnceId: "StopApp"
|
||||||
+475
@@ -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();
|
||||||
|
}
|
||||||
+319
@@ -0,0 +1,319 @@
|
|||||||
|
//! Автообновление через GitHub Releases.
|
||||||
|
//!
|
||||||
|
//! Раз в `CHECK_INTERVAL` спрашиваем последнюю версию репозитория; если она
|
||||||
|
//! новее вшитой — молча скачиваем установщик и запускаем его.
|
||||||
|
//!
|
||||||
|
//! Тонкость с правами. Сервер запускается от обычного пользователя, а
|
||||||
|
//! установщик пишет в Program Files и требует администратора. Чтобы обновление
|
||||||
|
//! шло без окна UAC, установщик (`installer/setup.iss`) заранее регистрирует
|
||||||
|
//! задачу Планировщика «SysHelperUpdate» — запуск от SYSTEM с наивысшими
|
||||||
|
//! правами. Мы лишь кладём свежий `setup.exe` в фиксированное место
|
||||||
|
//! (`{ProgramData}\SysHelper\update\setup.exe`) и дёргаем эту задачу: она
|
||||||
|
//! запускает установщик повышенно и тихо. Путь должен совпадать с тем, что
|
||||||
|
//! прописан в `[Run]` задачи в `setup.iss`.
|
||||||
|
|
||||||
|
use std::{path::PathBuf, time::Duration};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::Shared;
|
||||||
|
|
||||||
|
/// Версия из Cargo.toml — её же CI подставляет в тег релиза и имя установщика.
|
||||||
|
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
// Координаты репозитория. Поправьте, если owner/имя другие.
|
||||||
|
const GITHUB_OWNER: &str = "robonen";
|
||||||
|
const GITHUB_REPO: &str = "syshelper";
|
||||||
|
|
||||||
|
const CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
/// Имя задачи Планировщика — должно совпадать с `setup.iss`.
|
||||||
|
const TASK_NAME: &str = "SysHelperUpdate";
|
||||||
|
|
||||||
|
/* ── статус для веб-панели ──────────────────────────────────── */
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct UpdateStatus {
|
||||||
|
/// Установленная сейчас версия.
|
||||||
|
current: String,
|
||||||
|
/// Последняя известная версия (тег релиза) либо `current`, если проверка не находила новее.
|
||||||
|
latest: Option<String>,
|
||||||
|
available: bool,
|
||||||
|
/// idle | checking | downloading | installing
|
||||||
|
phase: String,
|
||||||
|
/// Время последней проверки, локальное.
|
||||||
|
checked_at: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
/// Идёт обновление — второй запуск не начинаем. В JSON не отдаём.
|
||||||
|
#[serde(skip)]
|
||||||
|
busy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateStatus {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
current: CURRENT_VERSION.to_string(),
|
||||||
|
latest: None,
|
||||||
|
available: false,
|
||||||
|
phase: "idle".to_string(),
|
||||||
|
checked_at: None,
|
||||||
|
error: None,
|
||||||
|
busy: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── работа с GitHub ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Release {
|
||||||
|
tag_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Asset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Найденное обновление: разобранная версия, исходный тег и ссылка на установщик.
|
||||||
|
struct Update {
|
||||||
|
tag: String,
|
||||||
|
installer_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_client() -> Result<reqwest::Client, String> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.user_agent(concat!("SysHelper/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.timeout(Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает `Some(update)`, если в последнем релизе версия новее текущей.
|
||||||
|
async fn fetch_update(client: &reqwest::Client) -> Result<Option<Update>, String> {
|
||||||
|
let url =
|
||||||
|
format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest");
|
||||||
|
|
||||||
|
let release: Release = client
|
||||||
|
.get(url)
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// теги вида v0.2.0 — semver парсит без ведущей v
|
||||||
|
let tag = release.tag_name.trim_start_matches('v');
|
||||||
|
let latest = semver::Version::parse(tag)
|
||||||
|
.map_err(|e| format!("не разобрать версию «{}»: {e}", release.tag_name))?;
|
||||||
|
let current = semver::Version::parse(CURRENT_VERSION)
|
||||||
|
.map_err(|e| format!("своя версия «{CURRENT_VERSION}» некорректна: {e}"))?;
|
||||||
|
|
||||||
|
if latest <= current {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let installer = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name.to_lowercase().ends_with("-setup.exe"))
|
||||||
|
.ok_or_else(|| "в релизе нет установщика *-setup.exe".to_string())?;
|
||||||
|
|
||||||
|
Ok(Some(Update {
|
||||||
|
tag: release.tag_name.clone(),
|
||||||
|
installer_url: installer.browser_download_url.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Куда кладём скачанный установщик. Совпадает с путём задачи в `setup.iss`.
|
||||||
|
fn installer_path() -> PathBuf {
|
||||||
|
let base = std::env::var_os("ProgramData")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
|
base.join("SysHelper").join("update").join("setup.exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download(client: &reqwest::Client, url: &str, dest: &PathBuf) -> Result<(), String> {
|
||||||
|
let bytes = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(dir) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
std::fs::write(dest, &bytes).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запускает установщик повышенно и тихо через заранее зарегистрированную задачу.
|
||||||
|
/// Если задачи нет (например, портативный запуск) — стартуем напрямую, тогда
|
||||||
|
/// Windows покажет запрос UAC.
|
||||||
|
fn launch_installer(dest: &PathBuf) -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let ran = Command::new("schtasks")
|
||||||
|
.args(["/Run", "/TN", TASK_NAME])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ran {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Command::new(dest)
|
||||||
|
.args([
|
||||||
|
"/VERYSILENT",
|
||||||
|
"/SUPPRESSMSGBOXES",
|
||||||
|
"/NORESTART",
|
||||||
|
"/NOCANCEL",
|
||||||
|
])
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = (dest, TASK_NAME);
|
||||||
|
Err("автоустановка поддерживается только на Windows".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отцепляет короткий скрипт: он переждёт установку и поднимет уже новую
|
||||||
|
/// версию в текущей пользовательской сессии. Установщик закроет наш процесс,
|
||||||
|
/// а этот дочерний cmd переживёт закрытие, так как не держит файлы программы.
|
||||||
|
fn schedule_restart() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
let exe = exe.display().to_string();
|
||||||
|
// ping вместо timeout — надёжнее работает в отцепленном процессе
|
||||||
|
let script = format!("ping -n 30 127.0.0.1 >nul & start \"\" \"{exe}\"");
|
||||||
|
let _ = Command::new("cmd").args(["/C", &script]).spawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── оркестрация ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
fn now_string() -> String {
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Одна проверка: смотрим релиз и, если версия новее, скачиваем и запускаем.
|
||||||
|
pub async fn check_now(state: Shared, client: reqwest::Client) {
|
||||||
|
{
|
||||||
|
let mut s = state.update.lock().await;
|
||||||
|
if s.busy {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.phase = "checking".to_string();
|
||||||
|
s.error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match fetch_update(&client).await {
|
||||||
|
Err(e) => {
|
||||||
|
{
|
||||||
|
let mut s = state.update.lock().await;
|
||||||
|
s.phase = "idle".to_string();
|
||||||
|
s.checked_at = Some(now_string());
|
||||||
|
s.error = Some(e.clone());
|
||||||
|
}
|
||||||
|
state.log(format!("обновление: не удалось проверить — {e}")).await;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
let mut s = state.update.lock().await;
|
||||||
|
s.phase = "idle".to_string();
|
||||||
|
s.checked_at = Some(now_string());
|
||||||
|
s.latest = Some(CURRENT_VERSION.to_string());
|
||||||
|
s.available = false;
|
||||||
|
}
|
||||||
|
Ok(Some(update)) => {
|
||||||
|
{
|
||||||
|
let mut s = state.update.lock().await;
|
||||||
|
s.busy = true;
|
||||||
|
s.available = true;
|
||||||
|
s.latest = Some(update.tag.clone());
|
||||||
|
s.checked_at = Some(now_string());
|
||||||
|
s.phase = "downloading".to_string();
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.log(format!("обновление: найдена версия {} — устанавливаю", update.tag))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let dest = installer_path();
|
||||||
|
if let Err(e) = download(&client, &update.installer_url, &dest).await {
|
||||||
|
let mut s = state.update.lock().await;
|
||||||
|
s.busy = false;
|
||||||
|
s.phase = "idle".to_string();
|
||||||
|
s.error = Some(e.clone());
|
||||||
|
drop(s);
|
||||||
|
state.log(format!("обновление: ошибка загрузки — {e}")).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut s = state.update.lock().await;
|
||||||
|
s.phase = "installing".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// сперва планируем перезапуск, потом запускаем установщик: он вот-вот
|
||||||
|
// закроет этот процесс
|
||||||
|
schedule_restart();
|
||||||
|
if let Err(e) = launch_installer(&dest) {
|
||||||
|
let mut s = state.update.lock().await;
|
||||||
|
s.busy = false;
|
||||||
|
s.phase = "idle".to_string();
|
||||||
|
s.error = Some(e.clone());
|
||||||
|
drop(s);
|
||||||
|
state
|
||||||
|
.log(format!("обновление: не удалось запустить установщик — {e}"))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.log(format!(
|
||||||
|
"обновление {} запущено; панель перезапустится автоматически",
|
||||||
|
update.tag
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверка по запросу из панели.
|
||||||
|
pub async fn check_spawn(state: Shared) {
|
||||||
|
match build_client() {
|
||||||
|
Ok(client) => check_now(state, client).await,
|
||||||
|
Err(e) => state.log(format!("обновление: {e}")).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Фоновый цикл: проверяем при старте и далее раз в `CHECK_INTERVAL`.
|
||||||
|
pub async fn run(state: Shared) {
|
||||||
|
// даём серверу подняться, прежде чем лезть в сеть
|
||||||
|
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||||
|
|
||||||
|
let client = match build_client() {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(e) => {
|
||||||
|
state.log(format!("автообновление отключено: {e}")).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
check_now(state.clone(), client.clone()).await;
|
||||||
|
tokio::time::sleep(CHECK_INTERVAL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const init = body
|
||||||
|
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||||
|
: {};
|
||||||
|
const res = await fetch(path, init);
|
||||||
|
if (!res.ok) throw new Error(`${path}: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── composables ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function useRules() {
|
||||||
|
const rules = ref({ mode: 'blacklist', blocked: [], allowed: [], enforce: true });
|
||||||
|
const load = async () => { rules.value = await api('/rules'); };
|
||||||
|
const block = (name) => api('/rules/block', { name }).then(load);
|
||||||
|
const allow = (name) => api('/rules/allow', { name }).then(load);
|
||||||
|
const remove = (name) => api('/rules/remove', { name }).then(load);
|
||||||
|
// patch мержим в текущее состояние: сервер принимает объект правил целиком
|
||||||
|
const save = (patch) => api('/rules', { ...rules.value, ...patch }).then(load);
|
||||||
|
return { rules, load, block, allow, remove, save };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useProcesses() {
|
||||||
|
const raw = ref([]);
|
||||||
|
const load = async () => { raw.value = await api('/processes'); };
|
||||||
|
const kill = (name) => api('/kill', { name }).then(load);
|
||||||
|
|
||||||
|
// одноимённые процессы схлопываем — иначе двадцать строк chrome.exe
|
||||||
|
const groups = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const p of raw.value) {
|
||||||
|
const key = p.name.toLowerCase();
|
||||||
|
if (!map.has(key)) map.set(key, { key, name: p.name, count: 0, mem: 0, blocked: p.blocked });
|
||||||
|
const g = map.get(key);
|
||||||
|
g.count += 1;
|
||||||
|
g.mem += p.memory_mb;
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => b.mem - a.mem);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groups, load, kill };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLog() {
|
||||||
|
const lines = ref([]);
|
||||||
|
const load = async () => { lines.value = await api('/log'); };
|
||||||
|
return { lines, load };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useUpdate() {
|
||||||
|
const info = ref({ current: '', latest: null, available: false, phase: 'idle', checked_at: null, error: null });
|
||||||
|
const load = async () => { info.value = await api('/update'); };
|
||||||
|
// после ручной проверки состояние меняется не мгновенно — перечитываем чуть позже
|
||||||
|
const check = () => api('/update/check', {}).then(() => setTimeout(load, 1500));
|
||||||
|
return { info, load, check };
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePolling(fn, ms) {
|
||||||
|
let id = null;
|
||||||
|
onMounted(() => { fn(); id = setInterval(fn, ms); });
|
||||||
|
onUnmounted(() => clearInterval(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── компоненты ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const NameChips = {
|
||||||
|
props: {
|
||||||
|
title: { type: String, required: true },
|
||||||
|
names: { type: Array, default: () => [] },
|
||||||
|
},
|
||||||
|
emits: ['remove'],
|
||||||
|
setup(props) {
|
||||||
|
return { sorted: computed(() => [...props.names].sort()) };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="chips">
|
||||||
|
<div class="muted">{{ title }}</div>
|
||||||
|
<span v-if="!sorted.length" class="muted">пусто</span>
|
||||||
|
<span v-for="name in sorted" :key="name" class="chip">
|
||||||
|
{{ name }}
|
||||||
|
<button title="убрать из списка" @click="$emit('remove', name)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const UpdateBar = {
|
||||||
|
props: { info: { type: Object, required: true } },
|
||||||
|
emits: ['check'],
|
||||||
|
setup(props) {
|
||||||
|
const busy = computed(() => ['checking', 'downloading', 'installing'].includes(props.info.phase));
|
||||||
|
const status = computed(() => {
|
||||||
|
const i = props.info;
|
||||||
|
const phases = { checking: 'проверка…', downloading: 'загрузка…', installing: 'установка…' };
|
||||||
|
if (phases[i.phase]) return phases[i.phase];
|
||||||
|
if (i.error) return 'не удалось проверить обновления';
|
||||||
|
if (i.available && i.latest && i.latest !== i.current) return 'доступна версия ' + i.latest;
|
||||||
|
return 'установлена последняя версия';
|
||||||
|
});
|
||||||
|
return { busy, status };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<section class="card">
|
||||||
|
<div class="row" style="justify-content:space-between">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0">Версия {{ info.current || '—' }}</h2>
|
||||||
|
<span :class="info.error ? 'warn' : 'muted'">{{ status }}</span>
|
||||||
|
</div>
|
||||||
|
<button :disabled="busy" @click="$emit('check')">Проверить обновления</button>
|
||||||
|
</div>
|
||||||
|
</section>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RulesPanel = {
|
||||||
|
components: { NameChips },
|
||||||
|
props: { rules: { type: Object, required: true } },
|
||||||
|
emits: ['update', 'remove'],
|
||||||
|
template: `
|
||||||
|
<section class="card">
|
||||||
|
<h2>Правила</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label>Режим:
|
||||||
|
<select :value="rules.mode" @change="$emit('update', { mode: $event.target.value })">
|
||||||
|
<option value="blacklist">чёрный список</option>
|
||||||
|
<option value="whitelist">белый список</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" :checked="rules.enforce"
|
||||||
|
@change="$emit('update', { enforce: $event.target.checked })">
|
||||||
|
применять правила
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="rules.mode === 'whitelist' && !rules.allowed.length" class="warn">
|
||||||
|
Белый список пуст — сервер намеренно ничего не завершает, иначе снесло бы систему.
|
||||||
|
</p>
|
||||||
|
<name-chips title="Заблокированы" :names="rules.blocked" @remove="$emit('remove', $event)" />
|
||||||
|
<name-chips title="Разрешены" :names="rules.allowed" @remove="$emit('remove', $event)" />
|
||||||
|
</section>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RandomKill = {
|
||||||
|
emits: ['schedule'],
|
||||||
|
setup(_, { emit }) {
|
||||||
|
const name = ref('');
|
||||||
|
const min = ref(5);
|
||||||
|
const max = ref(30);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const value = name.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
emit('schedule', {
|
||||||
|
name: value,
|
||||||
|
min_secs: Math.max(0, min.value) * 60,
|
||||||
|
max_secs: Math.max(1, max.value) * 60,
|
||||||
|
});
|
||||||
|
name.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return { name, min, max, submit };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<section class="card">
|
||||||
|
<h2>Случайное завершение</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input v-model="name" placeholder="game.exe" style="flex:1;min-width:140px"
|
||||||
|
@keyup.enter="submit">
|
||||||
|
<input v-model.number="min" type="number" min="0" style="width:70px"> —
|
||||||
|
<input v-model.number="max" type="number" min="1" style="width:70px">
|
||||||
|
<span class="muted">мин</span>
|
||||||
|
<button @click="submit">Запланировать</button>
|
||||||
|
</div>
|
||||||
|
</section>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProcessTable = {
|
||||||
|
props: {
|
||||||
|
groups: { type: Array, default: () => [] },
|
||||||
|
whitelist: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
emits: ['toggle', 'kill'],
|
||||||
|
setup(props) {
|
||||||
|
const filter = ref('');
|
||||||
|
const visible = computed(() => {
|
||||||
|
const q = filter.value.trim().toLowerCase();
|
||||||
|
return q ? props.groups.filter((g) => g.key.includes(q)) : props.groups;
|
||||||
|
});
|
||||||
|
return { filter, visible };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<section class="card">
|
||||||
|
<h2>Процессы</h2>
|
||||||
|
<div class="row" style="margin-bottom:10px">
|
||||||
|
<input v-model="filter" placeholder="фильтр по имени" style="flex:1">
|
||||||
|
<span class="muted">{{ visible.length }} из {{ groups.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Имя</th><th>Экз.</th><th>Память</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="g in visible" :key="g.key" :class="{ blocked: g.blocked }">
|
||||||
|
<td>{{ g.name }}</td>
|
||||||
|
<td>{{ g.count }}</td>
|
||||||
|
<td>{{ g.mem }} МБ</td>
|
||||||
|
<td class="right">
|
||||||
|
<button @click="$emit('toggle', g)">
|
||||||
|
{{ g.blocked ? (whitelist ? 'Внести в белый список' : 'Разрешить') : 'Блокировать' }}
|
||||||
|
</button>
|
||||||
|
<button class="danger" style="margin-left:6px" @click="$emit('kill', g.name)">
|
||||||
|
Убить
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventLog = {
|
||||||
|
props: { lines: { type: Array, default: () => [] } },
|
||||||
|
setup(props) {
|
||||||
|
return { recent: computed(() => props.lines.slice(-40).reverse().join('\n')) };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<section class="card">
|
||||||
|
<h2>Журнал</h2>
|
||||||
|
<pre>{{ recent || 'пусто' }}</pre>
|
||||||
|
</section>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── корневой компонент ────────────────────────────────────── */
|
||||||
|
|
||||||
|
const App = {
|
||||||
|
components: { UpdateBar, RulesPanel, RandomKill, ProcessTable, EventLog },
|
||||||
|
setup() {
|
||||||
|
const { rules, load: loadRules, block, allow, remove, save } = useRules();
|
||||||
|
const { groups, load: loadProcesses, kill } = useProcesses();
|
||||||
|
const { lines, load: loadLog } = useLog();
|
||||||
|
const { info: update, load: loadUpdate, check: checkUpdate } = useUpdate();
|
||||||
|
|
||||||
|
onMounted(loadRules);
|
||||||
|
usePolling(() => { loadProcesses(); loadLog(); loadUpdate(); }, 3000);
|
||||||
|
|
||||||
|
// в whitelist «заблокирован» означает отсутствие в allowed — действия зеркальные
|
||||||
|
const toggle = async (g) => {
|
||||||
|
if (rules.value.mode === 'whitelist') {
|
||||||
|
await (g.blocked ? allow(g.name) : remove(g.name));
|
||||||
|
} else {
|
||||||
|
await (g.blocked ? remove(g.name) : block(g.name));
|
||||||
|
}
|
||||||
|
loadProcesses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRandom = async (payload) => {
|
||||||
|
await api('/kill/random', payload);
|
||||||
|
loadLog();
|
||||||
|
};
|
||||||
|
|
||||||
|
const whitelist = computed(() => rules.value.mode === 'whitelist');
|
||||||
|
|
||||||
|
return { rules, groups, lines, update, whitelist, save, remove, toggle, kill, scheduleRandom, checkUpdate };
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<h1>Родительский контроль</h1>
|
||||||
|
<update-bar :info="update" @check="checkUpdate" />
|
||||||
|
<rules-panel :rules="rules" @update="save" @remove="remove" />
|
||||||
|
<random-kill @schedule="scheduleRandom" />
|
||||||
|
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
|
||||||
|
<event-log :lines="lines" />`,
|
||||||
|
};
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Родительский контроль</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
[v-cloak] { display: none; }
|
||||||
|
body { margin:0; padding:16px; background:#14161a; color:#e6e8eb;
|
||||||
|
font:14px/1.5 system-ui, "Segoe UI", sans-serif; }
|
||||||
|
h1 { font-size:18px; margin:0 0 16px; }
|
||||||
|
h2 { font-size:14px; margin:0 0 10px; color:#9aa4b2; font-weight:600; }
|
||||||
|
.card { background:#1c1f26; border:1px solid #2a2f3a; border-radius:10px;
|
||||||
|
padding:14px; margin-bottom:14px; }
|
||||||
|
button { background:#2a2f3a; color:#e6e8eb; border:1px solid #3a4150;
|
||||||
|
border-radius:6px; padding:4px 10px; cursor:pointer; font-size:13px; }
|
||||||
|
button:hover { background:#343a47; }
|
||||||
|
button.danger { border-color:#6b2b2b; color:#ff9b9b; }
|
||||||
|
button.danger:hover { background:#3a1f1f; }
|
||||||
|
input, select { background:#14161a; color:#e6e8eb; border:1px solid #3a4150;
|
||||||
|
border-radius:6px; padding:5px 8px; font-size:13px; }
|
||||||
|
table { width:100%; border-collapse:collapse; }
|
||||||
|
th { text-align:left; color:#9aa4b2; font-weight:500; padding:4px 6px;
|
||||||
|
border-bottom:1px solid #2a2f3a; font-size:12px; }
|
||||||
|
td { padding:4px 6px; border-bottom:1px solid #22262f; }
|
||||||
|
tr.blocked td:first-child { color:#ff9b9b; }
|
||||||
|
.scroll { max-height:340px; overflow:auto; }
|
||||||
|
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||||
|
.chips { margin-top:12px; }
|
||||||
|
.chip { display:inline-flex; align-items:center; gap:4px; background:#232833;
|
||||||
|
border:1px solid #3a4150; border-radius:20px; padding:3px 6px 3px 12px;
|
||||||
|
margin:6px 6px 0 0; font-size:13px; }
|
||||||
|
.chip button { border:0; background:none; color:#9aa4b2; padding:0 4px; }
|
||||||
|
.right { text-align:right; white-space:nowrap; }
|
||||||
|
.muted { color:#6b7484; }
|
||||||
|
.warn { color:#e0b872; }
|
||||||
|
pre { margin:0; font:12px/1.6 ui-monospace, Consolas, monospace; color:#9aa4b2;
|
||||||
|
white-space:pre-wrap; max-height:200px; overflow:auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" v-cloak></div>
|
||||||
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user