diff --git a/Cargo.lock b/Cargo.lock index a0e6ee8..3d87a01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,18 @@ dependencies = [ "libc", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.91" @@ -95,12 +107,36 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -142,7 +178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -165,6 +201,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -199,6 +244,27 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -274,6 +340,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -665,6 +741,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1139,6 +1226,7 @@ dependencies = [ name = "syshelper" version = "0.1.0" dependencies = [ + "argon2", "axum", "chrono", "rand 0.8.7", @@ -1148,6 +1236,7 @@ dependencies = [ "serde_json", "sysinfo", "tokio", + "windows-service", ] [[package]] @@ -1320,6 +1409,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1350,6 +1445,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1449,6 +1550,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -1540,6 +1647,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.61.2", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index e518737..c2cdff7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,10 +13,14 @@ rand = "0.8" chrono = "0.4" semver = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +argon2 = "0.5.3" [profile.release] opt-level = "z" lto = true codegen-units = 1 strip = true -panic = "abort" \ No newline at end of file +panic = "abort" + +[target."cfg(windows)".dependencies] +windows-service = "0.8.1" diff --git a/installer/setup.iss b/installer/setup.iss index b956088..44dcf99 100644 --- a/installer/setup.iss +++ b/installer/setup.iss @@ -1,4 +1,4 @@ -; Установщик «System Helper» для Windows x64. +; Установщик «System Helper» для Windows x64. ; Сборка: ISCC.exe /DAppVersion=0.1.0 installer\setup.iss ; Результат кладётся в dist\syshelper-<версия>-setup.exe @@ -10,11 +10,13 @@ #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" +#define AppName "System Helper" +#define AppExeName "syshelper.exe" +#define AppUrl "http://127.0.0.1:8787" +; Имя службы — должно совпадать с SERVICE_NAME в src/service.rs. +#define ServiceName "SysHelper" +; Каталог данных — должен совпадать с data_dir() в src/main.rs. +#define DataDir "{commonappdata}\SysHelper" [Setup] ; AppId определяет программу при обновлении и удалении — не менять между версиями. @@ -38,8 +40,10 @@ WizardStyle=modern ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible -; Program Files и автозапуск через HKLM требуют администратора. +; Регистрация службы и запись в Program Files требуют администратора. PrivilegesRequired=admin +; Страховка: службу мы останавливаем сами, но если файл всё же занят — +; пусть Inno закроет процесс, а не просит перезагрузку. CloseApplications=yes RestartApplications=no @@ -48,52 +52,193 @@ 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 +; Каталог данных службы: правила, журнал, хеш пароля, скачанные обновления. +; Права закрываем через icacls ниже — унаследованные от ProgramData позволяют +; обычному пользователю создавать здесь файлы. Он не должен ни читать хеш +; пароля, ни править правила, ни подменять установщик обновления. +Name: "{#DataDir}" [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 +; Ярлыки ведут на панель: своего окна у программы нет, работает она службой. +Name: "{group}\{#AppName}"; Filename: "{#AppUrl}" +Name: "{group}\{cm:UninstallProgram,{#AppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#AppName}"; Filename: "{#AppUrl}"; Tasks: desktopicon [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 +; Права на каталог данных выставляются в [Code] до записи пароля и старта +; службы — здесь было бы уже поздно: [Run] выполняется последним. Filename: "{#AppUrl}"; \ - Description: "Открыть веб-панель"; \ + 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" +[UninstallDelete] +; Правила, журнал и скачанный установщик сами не удалятся. +Type: filesandordirs; Name: "{#DataDir}" + +[Code] +function ServiceExists(): Boolean; +var + Code: Integer; +begin + // sc query для несуществующей службы возвращает 1060 + Result := Exec(ExpandConstant('{sys}\sc.exe'), 'query {#ServiceName}', '', + SW_HIDE, ewWaitUntilTerminated, Code) and (Code = 0); +end; + +function RunSc(const Params: String): Boolean; +var + Code: Integer; +begin + Result := Exec(ExpandConstant('{sys}\sc.exe'), Params, '', + SW_HIDE, ewWaitUntilTerminated, Code) and (Code = 0); +end; + +// Останавливаем через net stop, а не sc stop: net ждёт фактической остановки, +// тогда как sc возвращает управление сразу, оставив службу в STOP_PENDING. +// Если не дождаться, exe останется занят и обновление не встанет. +procedure StopService(); +var + Code: Integer; +begin + if not ServiceExists() then + Exit; + + Exec(ExpandConstant('{sys}\net.exe'), 'stop {#ServiceName}', '', + SW_HIDE, ewWaitUntilTerminated, Code); + // даём файловой системе отпустить exe + Sleep(1000); +end; + +// Страница пароля. Показываем только при первой установке: при обновлении +// пароль уже задан, а тихая установка (автообновление) проходит без мастера. +var + PasswordPage: TInputQueryWizardPage; + +function AuthConfigured(): Boolean; +begin + Result := FileExists(ExpandConstant('{#DataDir}\auth.hash')); +end; + +procedure InitializeWizard(); +begin + PasswordPage := CreateInputQueryPage(wpSelectTasks, + 'Пароль панели', + 'Защитите панель управления от того, кого она ограничивает.', + 'Панель открывается в браузере по адресу ' + '{#AppUrl}' + '. Без пароля ' + + 'её сможет открыть любой пользователь этого компьютера и отключить правила.'); + PasswordPage.Add('Пароль:', True); +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + Result := (PasswordPage <> nil) and (PageID = PasswordPage.ID) and AuthConfigured(); +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + if (PasswordPage <> nil) and (CurPageID = PasswordPage.ID) + and (Trim(PasswordPage.Values[0]) = '') then + begin + MsgBox('Задайте пароль — иначе панель останется открытой для всех.', + mbError, MB_OK); + Result := False; + end; +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + StopService(); + Result := ''; +end; + +// Пароль передаём файлом, а не аргументом командной строки: аргумент был бы +// виден в списке процессов. Файл пишем в UTF-8 явно — Rust читает его так же, +// иначе кириллический пароль исказился бы и вход перестал работать. +procedure SetPassword(); +var + PwFile: String; + Code: Integer; +begin + // При тихой установке (автообновление) мастера нет и страницы тоже — + // обращение к ней уронило бы установщик посреди обновления. + if (PasswordPage = nil) or AuthConfigured() or (Trim(PasswordPage.Values[0]) = '') then + Exit; + + PwFile := ExpandConstant('{tmp}\pw.txt'); + if not SaveStringsToUTF8File(PwFile, [PasswordPage.Values[0]], False) then + Exit; + + // exe сам удалит файл после чтения + Exec(ExpandConstant('{app}\{#AppExeName}'), + '--set-password-file "' + PwFile + '"', '', + SW_HIDE, ewWaitUntilTerminated, Code); +end; + +procedure InstallService(); +var + Bin: String; +begin + // Путь к exe содержит пробелы (Program Files), поэтому его кавычки должны + // попасть внутрь значения binPath — sc.exe ждёт их экранированными, иначе + // служба зарегистрируется с обрезанным путём и не запустится. + Bin := 'binPath= "\"' + ExpandConstant('{app}\{#AppExeName}') + '\" --service"'; + + if ServiceExists() then + // пути могли измениться при переустановке в другой каталог + RunSc('config {#ServiceName} ' + Bin + ' start= delayed-auto') + else + RunSc('create {#ServiceName} ' + Bin + ' start= delayed-auto ' + + 'DisplayName= "{#AppName}"'); + + RunSc('description {#ServiceName} "Родительский контроль: следит за запуском программ."'); + // Родительский контроль не должен исчезать из-за одной аварии. + RunSc('failure {#ServiceName} reset= 86400 actions= restart/5000/restart/5000/restart/60000'); + RunSc('start {#ServiceName}'); +end; + +// Каталог данных закрываем от обычных пользователей: унаследованные от +// ProgramData права позволяют им создавать здесь файлы. Ребёнок не должен ни +// читать хеш пароля, ни править правила, ни подменять установщик обновления. +// SID вместо имён групп — имена локализованы, «Administrators» на русской +// системе не существует. +procedure LockDownDataDir(); +var + Code: Integer; +begin + Exec(ExpandConstant('{sys}\icacls.exe'), + '"' + ExpandConstant('{#DataDir}') + '" /inheritance:r /grant:r ' + + '*S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F', + '', SW_HIDE, ewWaitUntilTerminated, Code); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep <> ssPostInstall then + Exit; + + // Порядок важен: сначала закрываем каталог, потом кладём туда хеш пароля и + // только затем поднимаем службу. + LockDownDataDir(); + SetPassword(); + InstallService(); +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep <> usUninstall then + Exit; + + StopService(); + RunSc('delete {#ServiceName}'); +end; diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..4e7d0a6 --- /dev/null +++ b/src/auth.rs @@ -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>, + /// Токен -> когда выдан. + sessions: Mutex>, +} + +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 { + 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, 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 { + Html(LOGIN_HTML.replace("", message)) +} + +pub async fn login_form(State(state): State) -> Html { + 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, Form(req): Form) -> 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, req: Request) -> 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() +} diff --git a/src/main.rs b/src/main.rs index 2ece211..26e0787 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 = 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> = 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 = 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 { + let text = std::fs::read_to_string(log_path()).unwrap_or_default(); + let lines: Vec = 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, log: Mutex>, update: Mutex, + auth: auth::Auth, } type Shared = Arc; impl AppState { async fn log(&self, msg: impl Into) { - 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 { 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 { } /// Раз в SCAN_INTERVAL применяет правила ко всем процессам. -async fn watchdog(state: Shared) { +async fn watchdog(state: Shared, mut shutdown: tokio::sync::watch::Receiver) { 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) -> Json> { 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, Json(req): Json) -> 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) -> Json) -> Json { - // не блокируем ответ: проверка и возможная установка идут в фоне - 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 = 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, +) -> Option { + 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) { 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; } \ No newline at end of file diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..1bcf26d --- /dev/null +++ b/src/service.rs @@ -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) { + 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(()) +} diff --git a/src/update.rs b/src/update.rs index 428816a..5496b82 100644 --- a/src/update.rs +++ b/src/update.rs @@ -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, available: bool, - /// idle | checking | downloading | installing - phase: String, + phase: Phase, /// Время последней проверки, локальное. checked_at: Option, error: Option, - /// Идёт обновление — второй запуск не начинаем. В JSON не отдаём. + /// Когда в последний раз запускали установку. #[serde(skip)] - busy: bool, + started_at: Option, } 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, } -fn build_client() -> Result { - 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> = 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, String> { - let url = - format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest"); +async fn fetch_latest(client: &reqwest::Client) -> Result { + 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, 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; } } diff --git a/static/app.js b/static/app.js index 28b0d1e..351bd2c 100644 --- a/static/app.js +++ b/static/app.js @@ -5,6 +5,11 @@ async function api(path, body) { ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}; const res = await fetch(path, init); + // сессия истекла — иначе открытая вкладка молча перестала бы обновляться + if (res.status === 401) { + location.href = '/login'; + throw new Error('нужен вход'); + } if (!res.ok) throw new Error(`${path}: ${res.status}`); return res.json(); } @@ -89,25 +94,32 @@ 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; + if (i.phase === 'checking') return 'проверка…'; + if (i.phase === 'installing') return `устанавливается ${i.latest}, панель перезапустится`; + if (i.error) return i.error; + if (i.available) return `доступна версия ${i.latest}`; return 'установлена последняя версия'; }); - return { busy, status }; + return { + status, + checking: computed(() => props.info.phase === 'checking'), + bad: computed(() => Boolean(props.info.error)), + }; }, template: `

Версия {{ info.current || '—' }}

- {{ status }} + {{ status }}
- + +
+
+ Установщик, {{ info.installer.time }}: + {{ info.installer.message }}
`, }; @@ -243,7 +255,9 @@ const App = { const { info: update, load: loadUpdate, check: checkUpdate } = useUpdate(); onMounted(loadRules); - usePolling(() => { loadProcesses(); loadLog(); loadUpdate(); }, 3000); + usePolling(() => { loadProcesses(); loadLog(); }, 3000); + // версия меняется раз в сутки в лучшем случае — частить незачем + usePolling(loadUpdate, 30000); // в whitelist «заблокирован» означает отсутствие в allowed — действия зеркальные const toggle = async (g) => { @@ -265,7 +279,10 @@ const App = { return { rules, groups, lines, update, whitelist, save, remove, toggle, kill, scheduleRandom, checkUpdate }; }, template: ` -

Родительский контроль

+
+

Родительский контроль

+
+
diff --git a/static/index.html b/static/index.html index 66f7b9e..37a3ed6 100644 --- a/static/index.html +++ b/static/index.html @@ -17,6 +17,7 @@ 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:disabled { opacity:.5; cursor:default; background:#2a2f3a; } button.danger { border-color:#6b2b2b; color:#ff9b9b; } button.danger:hover { background:#3a1f1f; } input, select { background:#14161a; color:#e6e8eb; border:1px solid #3a4150; diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..34a1ae4 --- /dev/null +++ b/static/login.html @@ -0,0 +1,35 @@ + + + + + +Вход — Родительский контроль + + + +
+

Родительский контроль

+ + + +

+
+ +