feat: implement Windows service for parental control application

This commit is contained in:
2026-08-02 16:24:53 +07:00
parent 32d2553380
commit 229a02b543
10 changed files with 1111 additions and 272 deletions
+185 -40
View File
@@ -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;