feat: implement Windows service for parental control application
This commit is contained in:
Generated
+119
-1
@@ -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"
|
||||
|
||||
+5
-1
@@ -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"
|
||||
panic = "abort"
|
||||
|
||||
[target."cfg(windows)".dependencies]
|
||||
windows-service = "0.8.1"
|
||||
|
||||
+185
-40
@@ -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;
|
||||
|
||||
+205
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+27
-10
@@ -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: `
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div>
|
||||
<h2 style="margin:0">Версия {{ info.current || '—' }}</h2>
|
||||
<span :class="info.error ? 'warn' : 'muted'">{{ status }}</span>
|
||||
<span :class="bad ? 'warn' : 'muted'">{{ status }}</span>
|
||||
</div>
|
||||
<button :disabled="busy" @click="$emit('check')">Проверить обновления</button>
|
||||
<button :disabled="checking" @click="$emit('check')">Проверить обновления</button>
|
||||
</div>
|
||||
<div v-if="info.installer" class="muted" style="margin-top:8px">
|
||||
Установщик, {{ info.installer.time }}:
|
||||
<span :class="{ warn: !info.installer.ok }">{{ info.installer.message }}</span>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
@@ -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: `
|
||||
<h1>Родительский контроль</h1>
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<h1>Родительский контроль</h1>
|
||||
<form method="post" action="/logout"><button>Выйти</button></form>
|
||||
</div>
|
||||
<update-bar :info="update" @check="checkUpdate" />
|
||||
<rules-panel :rules="rules" @update="save" @remove="remove" />
|
||||
<random-kill @schedule="scheduleRandom" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Вход — Родительский контроль</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; min-height:100vh; display:grid; place-items:center;
|
||||
background:#14161a; color:#e6e8eb;
|
||||
font:14px/1.5 system-ui, "Segoe UI", sans-serif; }
|
||||
form { background:#1c1f26; border:1px solid #2a2f3a; border-radius:10px;
|
||||
padding:22px; width:min(320px, 90vw); }
|
||||
h1 { font-size:16px; margin:0 0 14px; }
|
||||
label { display:block; color:#9aa4b2; font-size:13px; margin-bottom:6px; }
|
||||
input { width:100%; background:#14161a; color:#e6e8eb; border:1px solid #3a4150;
|
||||
border-radius:6px; padding:8px; font-size:14px; }
|
||||
button { width:100%; margin-top:14px; background:#2a2f3a; color:#e6e8eb;
|
||||
border:1px solid #3a4150; border-radius:6px; padding:8px;
|
||||
cursor:pointer; font-size:14px; }
|
||||
button:hover { background:#343a47; }
|
||||
p.msg { margin:12px 0 0; color:#e0b872; font-size:13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form method="post" action="/login">
|
||||
<h1>Родительский контроль</h1>
|
||||
<label for="password">Пароль</label>
|
||||
<input id="password" name="password" type="password" autofocus required>
|
||||
<button type="submit">Войти</button>
|
||||
<p class="msg"><!--MESSAGE--></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user