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
+205
View File
@@ -0,0 +1,205 @@
//! Пароль на панель.
//!
//! Без него перенос в службу не имеет смысла: процесс стал неубиваемым, но
//! панель открыта всем на localhost, и ограниченный пользователь просто снял бы
//! галочку «применять правила». Пароль задаётся установщиком — там уже есть
//! администратор, и не возникает промежутка, когда пароль ещё не задан и его
//! может поставить кто угодно.
use std::{
collections::HashMap,
time::{Duration, Instant},
};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use axum::{
body::Body,
extract::{Request, State},
http::{header, StatusCode},
middleware::Next,
response::{Html, IntoResponse, Redirect, Response},
Form,
};
use rand::Rng;
use serde::Deserialize;
use tokio::sync::Mutex;
use crate::{auth_path, Shared};
const COOKIE: &str = "sh_session";
const LOGIN_HTML: &str = include_str!("../static/login.html");
/// Пауза после неверного пароля: перебор должен быть скучным.
const WRONG_PASSWORD_DELAY: Duration = Duration::from_secs(1);
/// Срок жизни сессии. Вечная cookie означала бы, что забытый открытым браузер
/// родителя остаётся ключом к панели навсегда.
const SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60);
pub struct Auth {
/// Строка формата PHC (`$argon2id$...`), либо None — пароль ещё не задан.
hash: Mutex<Option<String>>,
/// Токен -> когда выдан.
sessions: Mutex<HashMap<String, Instant>>,
}
impl Auth {
pub fn load() -> Self {
Self {
hash: Mutex::new(
std::fs::read_to_string(auth_path())
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
),
sessions: Mutex::new(HashMap::new()),
}
}
}
/// Считает и сохраняет хеш пароля. Вызывается установщиком через
/// `--set-password-file`: сам пароль в командной строке не светится, иначе его
/// было бы видно в списке процессов.
pub fn set_password(password: &str) -> Result<(), String> {
let password = password.trim();
if password.is_empty() {
return Err("пустой пароль".to_string());
}
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| e.to_string())?
.to_string();
std::fs::write(auth_path(), hash).map_err(|e| e.to_string())
}
fn verify(hash: &str, password: &str) -> bool {
PasswordHash::new(hash).is_ok_and(|parsed| {
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
})
}
fn new_token() -> String {
let bytes: [u8; 32] = rand::thread_rng().gen();
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn session_from(req: &Request) -> Option<String> {
let raw = req.headers().get(header::COOKIE)?.to_str().ok()?;
raw.split(';')
.filter_map(|pair| pair.split_once('='))
.find(|(name, _)| name.trim() == COOKIE)
.map(|(_, value)| value.trim().to_string())
}
/// Пропускает дальше только со своей сессией.
pub async fn guard(State(state): State<Shared>, req: Request, next: Next) -> Response {
// Страница входа и так объявлена вне защиты; проверка — страховка на
// случай, если маршруты когда-нибудь переставят местами.
if req.uri().path() == "/login" {
return next.run(req).await;
}
let known = match session_from(&req) {
Some(token) => {
let mut sessions = state.auth.sessions.lock().await;
sessions.retain(|_, issued| issued.elapsed() < SESSION_TTL);
sessions.contains_key(&token)
}
None => false,
};
if known {
return next.run(req).await;
}
// Панель — на страницу входа, запросы её кода — коротким отказом:
// иначе fetch получил бы HTML вместо JSON и молча сломался.
if req.method() == axum::http::Method::GET && req.uri().path() == "/" {
Redirect::to("/login").into_response()
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
fn login_page(message: &str) -> Html<String> {
Html(LOGIN_HTML.replace("<!--MESSAGE-->", message))
}
pub async fn login_form(State(state): State<Shared>) -> Html<String> {
if state.auth.hash.lock().await.is_none() {
return login_page("Пароль ещё не задан — придумайте его сейчас.");
}
login_page("")
}
#[derive(Deserialize)]
pub struct LoginReq {
password: String,
}
pub async fn login(State(state): State<Shared>, Form(req): Form<LoginReq>) -> Response {
let stored = state.auth.hash.lock().await.clone();
match stored {
// Пароля нет — установщик его не задал. Принимаем первый введённый:
// иначе панель осталась бы недоступной вообще никому.
None => {
if let Err(e) = set_password(&req.password) {
return login_page(&format!("Не удалось сохранить пароль: {e}")).into_response();
}
*state.auth.hash.lock().await = std::fs::read_to_string(auth_path())
.ok()
.map(|s| s.trim().to_string());
state.log("задан пароль панели").await;
}
Some(hash) => {
if !verify(&hash, &req.password) {
tokio::time::sleep(WRONG_PASSWORD_DELAY).await;
state.log("неудачная попытка входа в панель").await;
return login_page("Неверный пароль.").into_response();
}
}
}
let token = new_token();
state
.auth
.sessions
.lock()
.await
.insert(token.clone(), Instant::now());
(
[(
header::SET_COOKIE,
// Secure не ставим: панель работает по http на localhost.
// Max-Age согласован с SESSION_TTL — иначе браузер слал бы cookie,
// которую сервер уже забыл.
format!(
"{COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}",
SESSION_TTL.as_secs()
),
)],
Redirect::to("/"),
)
.into_response()
}
pub async fn logout(State(state): State<Shared>, req: Request<Body>) -> Response {
if let Some(token) = session_from(&req) {
state.auth.sessions.lock().await.remove(&token);
}
(
[(
header::SET_COOKIE,
format!("{COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"),
)],
Redirect::to("/login"),
)
.into_response()
}
+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;
}
+89
View File
@@ -0,0 +1,89 @@
//! Обвязка службы Windows.
//!
//! Служба работает от SYSTEM в сеансе 0, где показать окно нельзя. Программе
//! это и не нужно: панель — обычный HTTP на localhost, а петлевой интерфейс
//! общий для всей машины, так что браузер из сеанса пользователя открывает её
//! как раньше.
//!
//! Ради этого всё и затевалось: снять службу без прав администратора нельзя,
//! поэтому ограниченный пользователь больше не выключает контроль через
//! диспетчер задач.
use std::{ffi::OsString, time::Duration};
use windows_service::{
define_windows_service,
service::{
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher,
};
use crate::{append_log_file, stamp};
/// Имя службы — должно совпадать с `installer/setup.iss`.
pub const SERVICE_NAME: &str = "SysHelper";
define_windows_service!(ffi_service_main, service_main);
/// Передаёт процесс диспетчеру SCM. Возвращается только при остановке службы.
pub fn start() {
if let Err(e) = service_dispatcher::start(SERVICE_NAME, ffi_service_main) {
// Сюда попадаем, если exe запустили с --service вручную, а не через SCM.
append_log_file(&format!("[{}] не удалось стать службой: {e}", stamp()));
}
}
fn service_main(_args: Vec<OsString>) {
if let Err(e) = run() {
append_log_file(&format!("[{}] служба завершилась с ошибкой: {e}", stamp()));
}
}
fn run() -> windows_service::Result<()> {
// watch::Sender::send синхронный, поэтому его можно дёрнуть прямо из
// обработчика SCM — тот вызывается из чужого потока и ждать не умеет.
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let handler = move |control| match control {
ServiceControl::Stop | ServiceControl::Shutdown => {
let _ = shutdown_tx.send(true);
ServiceControlHandlerResult::NoError
}
// На Interrogate обязаны ответить, иначе SCM считает службу зависшей.
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
};
let handle = service_control_handler::register(SERVICE_NAME, handler)?;
let report = |state, accepted, wait_hint| ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: state,
controls_accepted: accepted,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint,
process_id: None,
};
handle.set_service_status(report(
ServiceState::Running,
ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
Duration::default(),
))?;
match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime.block_on(crate::serve(shutdown_rx)),
Err(e) => append_log_file(&format!("[{}] нет рантайма: {e}", stamp())),
}
handle.set_service_status(report(
ServiceState::Stopped,
ServiceControlAccept::empty(),
Duration::default(),
))?;
Ok(())
}
+185 -182
View File
@@ -1,20 +1,21 @@
//! Автообновление через GitHub Releases.
//!
//! Раз в `CHECK_INTERVAL` спрашиваем последнюю версию репозитория; если она
//! новее вшитой — молча скачиваем установщик и запускаем его.
//! Служба и так работает от SYSTEM, поэтому скачивает и ставит обновление
//! сама: ни задач Планировщика, ни отдельного привилегированного режима больше
//! не нужно. Каталог загрузки лежит в ProgramData и закрыт от записи обычным
//! пользователям (icacls в `installer/setup.iss`) — иначе ограниченная учётная
//! запись подменила бы установщик и получила SYSTEM.
//!
//! Тонкость с правами. Сервер запускается от обычного пользователя, а
//! установщик пишет в Program Files и требует администратора. Чтобы обновление
//! шло без окна UAC, установщик (`installer/setup.iss`) заранее регистрирует
//! задачу Планировщика «SysHelperUpdate» — запуск от SYSTEM с наивысшими
//! правами. Мы лишь кладём свежий `setup.exe` в фиксированное место
//! (`{ProgramData}\SysHelper\update\setup.exe`) и дёргаем эту задачу: она
//! запускает установщик повышенно и тихо. Путь должен совпадать с тем, что
//! прописан в `[Run]` задачи в `setup.iss`.
//! Останавливает и поднимает службу сам установщик (см. `[Code]` в setup.iss),
//! так что возвращать панель на место после обновления не требуется.
use std::{path::PathBuf, time::Duration};
use std::{
path::PathBuf,
sync::OnceLock,
time::{Duration, Instant},
};
#[cfg(windows)]
use std::process::Command;
use std::{os::windows::process::CommandExt, process::Command};
use serde::{Deserialize, Serialize};
@@ -23,43 +24,67 @@ 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";
/// Даём службе подняться, прежде чем лезть в сеть.
const STARTUP_DELAY: Duration = Duration::from_secs(20);
/// Повторять установку раньше этого срока бессмысленно: либо она идёт, либо
/// сломалась и нужен человек. Кнопка в панели ограничение обходит.
const RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
/// Ключи тихой установки. `/FORCECLOSEAPPLICATIONS` оставляем страховкой на
/// случай, если остановка службы почему-то не сработала и файлы заняты.
#[cfg(windows)]
const INSTALL_ARGS: &[&str] = &[
"/VERYSILENT",
"/SUPPRESSMSGBOXES",
"/FORCECLOSEAPPLICATIONS",
"/NORESTART",
];
#[cfg(windows)]
use crate::CREATE_NO_WINDOW;
/* ── статус для веб-панели ──────────────────────────────────── */
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Phase {
Idle,
Checking,
Downloading,
Installing,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateStatus {
/// Установленная сейчас версия.
current: String,
/// Последняя известная версия (тег релиза) либо `current`, если проверка не находила новее.
current: &'static str,
/// Последний тег в релизах — как есть, даже если он не новее текущего.
latest: Option<String>,
available: bool,
/// idle | checking | downloading | installing
phase: String,
phase: Phase,
/// Время последней проверки, локальное.
checked_at: Option<String>,
error: Option<String>,
/// Идёт обновление — второй запуск не начинаем. В JSON не отдаём.
/// Когда в последний раз запускали установку.
#[serde(skip)]
busy: bool,
started_at: Option<Instant>,
}
impl UpdateStatus {
pub fn new() -> Self {
Self {
current: CURRENT_VERSION.to_string(),
current: CURRENT_VERSION,
latest: None,
available: false,
phase: "idle".to_string(),
phase: Phase::Idle,
checked_at: None,
error: None,
busy: false,
started_at: None,
}
}
}
@@ -79,24 +104,32 @@ struct Asset {
browser_download_url: String,
}
/// Найденное обновление: разобранная версия, исходный тег и ссылка на установщик.
struct Update {
/// Последний релиз: тег как есть, новее ли он текущей версии и ссылка на
/// установщик, если он в релизе приложен.
struct Latest {
tag: String,
installer_url: String,
newer: bool,
installer_url: Option<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())
/// Клиент переживает процесс: пересобирать его на каждую проверку значит
/// каждый раз заново разбирать корневые сертификаты.
fn client() -> Result<&'static reqwest::Client, String> {
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
CLIENT
.get_or_init(|| {
reqwest::Client::builder()
.user_agent(concat!("SysHelper/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(300))
.build()
.map_err(|e| e.to_string())
})
.as_ref()
.map_err(Clone::clone)
}
/// Возвращает `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");
async fn fetch_latest(client: &reqwest::Client) -> Result<Latest, String> {
let url = format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest");
let release: Release = client
.get(url)
@@ -111,34 +144,27 @@ async fn fetch_update(client: &reqwest::Client) -> Result<Option<Update>, String
.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)
let latest = semver::Version::parse(release.tag_name.trim_start_matches('v'))
.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(),
}))
Ok(Latest {
newer: latest > current,
tag: release.tag_name,
installer_url: release
.assets
.into_iter()
.find(|a| a.name.to_lowercase().ends_with("-setup.exe"))
.map(|a| a.browser_download_url),
})
}
/// Куда кладём скачанный установщик. Совпадает с путём задачи в `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")
/* ── загрузка и установка ───────────────────────────────────── */
/// Каталог загрузки: писать сюда может только служба и администраторы.
fn download_path() -> PathBuf {
crate::data_dir().join("update").join("setup.exe")
}
async fn download(client: &reqwest::Client, url: &str, dest: &PathBuf) -> Result<(), String> {
@@ -159,161 +185,138 @@ async fn download(client: &reqwest::Client, url: &str, dest: &PathBuf) -> Result
std::fs::write(dest, &bytes).map_err(|e| e.to_string())
}
/// Запускает установщик повышенно и тихо через заранее зарегистрированную задачу.
/// Если задачи нет (например, портативный запуск) — стартуем напрямую, тогда
/// Windows покажет запрос UAC.
fn launch_installer(dest: &PathBuf) -> Result<(), String> {
/// Запускает установщик и сразу отпускает: он остановит службу, то есть нас,
/// и ждать его завершения бессмысленно. Дочерний процесс переживает родителя.
fn launch_installer(installer: &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",
])
let log = format!("/LOG={}", crate::data_dir().join("install.log").display());
Command::new(installer)
.args(INSTALL_ARGS)
.arg(log)
.creation_flags(CREATE_NO_WINDOW)
.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();
let _ = installer;
Err("установка поддерживается только на Windows".to_string())
}
}
/* ── оркестрация ────────────────────────────────────────────── */
fn now_string() -> String {
chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
crate::stamp()
}
/// Одна проверка: смотрим релиз и, если версия новее, скачиваем и запускаем.
pub async fn check_now(state: Shared, client: reqwest::Client) {
/// Проверяет версию и, если вышла новее, ставит её.
/// `force` — нажатие кнопки в панели: игнорирует паузу между попытками.
pub async fn check(state: Shared, force: bool) {
{
let mut s = state.update.lock().await;
if s.busy {
let mut status = state.update.lock().await;
if status.phase != Phase::Idle && status.phase != Phase::Installing {
return; // проверка или загрузка уже идёт
}
let waiting = status
.started_at
.is_some_and(|at| at.elapsed() < RETRY_AFTER);
if waiting && !force {
return;
}
s.phase = "checking".to_string();
s.error = None;
status.phase = Phase::Checking;
}
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 http = client();
let fetched = match http {
Ok(client) => fetch_latest(client).await,
Err(ref e) => Err(e.clone()),
};
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 latest = {
let mut status = state.update.lock().await;
status.checked_at = Some(now_string());
{
let mut s = state.update.lock().await;
s.phase = "installing".to_string();
match fetched {
Ok(latest) => {
status.error = None;
status.available = latest.newer;
status.latest = Some(latest.tag.clone());
if !latest.newer {
status.phase = Phase::Idle;
return;
}
status.phase = Phase::Downloading;
status.started_at = Some(Instant::now());
latest
}
// сперва планируем перезапуск, потом запускаем установщик: он вот-вот
// закроет этот процесс
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);
Err(e) => {
status.phase = Phase::Idle;
status.error = Some(e.clone());
drop(status);
state
.log(format!("обновление: не удалось запустить установщик{e}"))
.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;
}
};
let Some(url) = latest.installer_url else {
let mut status = state.update.lock().await;
status.phase = Phase::Idle;
status.error = Some("в релизе нет установщика *-setup.exe".to_string());
drop(status);
state
.log(format!("обновление {}: нет установщика в релизе", latest.tag))
.await;
return;
};
state
.log(format!("обновление: найдена версия {}, скачиваю", latest.tag))
.await;
// сюда попадаем только после удачного fetch_latest, значит клиент есть
let Ok(http) = http else { return };
let dest = download_path();
if let Err(e) = download(http, &url, &dest).await {
let mut status = state.update.lock().await;
status.phase = Phase::Idle;
status.error = Some(e.clone());
drop(status);
state.log(format!("обновление: загрузка не удалась — {e}")).await;
return;
}
{
let mut status = state.update.lock().await;
status.phase = Phase::Installing;
}
// пишем до запуска: установщик вот-вот остановит службу
state
.log(format!("обновление {}: установка запущена", latest.tag))
.await;
if let Err(e) = launch_installer(&dest) {
let mut status = state.update.lock().await;
status.phase = Phase::Idle;
status.error = Some(e.clone());
drop(status);
state
.log(format!("обновление: установщик не запустился — {e}"))
.await;
}
}
/// Фоновый цикл проверки обновлений.
pub async fn run(state: Shared) {
tokio::time::sleep(STARTUP_DELAY).await;
loop {
check_now(state.clone(), client.clone()).await;
check(state.clone(), false).await;
tokio::time::sleep(CHECK_INTERVAL).await;
}
}