diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b9b5a1..8e4ef01 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,6 +100,12 @@ jobs: run: ./build.ps1 -Arch $env:PATHMAN_ARCH -VersionName '0.0.0' -VersionNumber '0.0.0' -ReleaseType official - name: Build, run and package run: ./build.ps1 -Arch $env:PATHMAN_ARCH -VersionName $env:PATHMAN_VERSION -VersionNumber $env:PATHMAN_VERSION_NUMBER -ReleaseType $env:PATHMAN_RELEASE_TYPE -Test + - name: Test origin tracking authorization + if: matrix.arch == 'x64' && !env.ACT + run: ./tests/origin-task.ps1 + - name: Test unified monitoring service + if: matrix.arch == 'x64' && !env.ACT + run: ./tests/global-monitor.ps1 - name: Accept final installers if: ${{ !env.ACT }} run: ./tests/installer-acceptance.ps1 -Architecture $env:PATHMAN_ARCH -Version $env:PATHMAN_VERSION diff --git a/README.md b/README.md index bc35721..5b83fa7 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,9 @@ PATH 不可作为版本目标。内置系统变量默认不可选,可在“设 用户和系统范围分别提供外部变量归类、PATH 去重与迁移选项,也可排除系统环境编辑器或 PowerToys 的修改。用户外部路径迁入 `EXTERNAL_USER_PATH`,系统外部路径迁入 `EXTERNAL_PATH`。 -“精确识别修改来源”需要管理员权限,用于记录写入进程并应用排除规则;来源无法确认时保留原值。系统 PATH 自动整理也需要管理员授权。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。 +“精确识别修改来源”首次启用时需要管理员授权,之后由计划任务启动只读的来源采集程序,登录时不会重复弹出 UAC。管理员本人的采集任务使用其提权令牌,跨用户授权的采集任务使用 SYSTEM。监控主进程仍以当前用户权限运行。来源无法确认时保留原值,个人模式下的系统 PATH 自动整理仍需单独授权。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。 + +为所有用户安装时,“系统变量”下面还提供“全局设置”。开启“为所有用户开启统一设置”后,由一个自动启动的 Windows 服务承担监控与来源采集,个人后台程序退出;个人的系统 PATH 整理选项停用,其保存的值保留。全局设置可分别启用非管理员用户的精确来源识别和系统 PATH 自动整理,配置、机器级记录及备份保存在受保护的 `%ProgramData%\PathMan\monitor`。服务按用户令牌处理各自的用户变量与个人配置。关闭统一设置后,恢复符合个人后台运行和登录自启条件的后台程序。仅为自己安装时不显示全局设置。 ### 设置与关于 diff --git a/packaging/PathMan.nsi b/packaging/PathMan.nsi index e56af42..4e10110 100644 --- a/packaging/PathMan.nsi +++ b/packaging/PathMan.nsi @@ -670,6 +670,12 @@ Section "PathMan" Main SetErrorLevel 1603 Abort ${EndIf} + System::Call '$Helper::MaintainOriginTask(w "$INSTDIR", w "$UserLocalData", w "$OriginalUserSid", i $Machine) i.r0' + ${If} $0 != 0 + MessageBox MB_OK|MB_ICONSTOP "$(MaintenanceFailed)" /SD IDOK + SetErrorLevel 1603 + Abort + ${EndIf} InstallUserFinalization: ${If} $Worker == 1 Call RestoreBackground @@ -752,6 +758,12 @@ Section "Uninstall" SetErrorLevel 0 Return ${EndIf} + System::Call '$Helper::RemoveOriginTasks(w "$INSTDIR", i $Machine) i.r0' + ${If} $0 != 0 + MessageBox MB_OK|MB_ICONSTOP "$(MaintenanceFailed)" /SD IDOK + SetErrorLevel 1603 + Abort + ${EndIf} System::Call '$Helper::CleanupInstall(w "$INSTDIR", i $Machine, w "$DESKTOP") i.r0' ${If} $0 != 0 MessageBox MB_OK|MB_ICONSTOP "$(MaintenanceFailed)" /SD IDOK @@ -766,6 +778,7 @@ Section "Uninstall" !insertmacro DeleteInstalledFile "PathMan.exe" !insertmacro DeleteInstalledFile "LICENSE" !insertmacro DeleteInstalledFile "release.json" + !insertmacro DeleteInstalledFile "internal\service-payload.json" !insertmacro DeleteInstalledFile "THIRD-PARTY-NOTICES.txt" !insertmacro DeleteInstalledFile "licenses\dotnet-THIRD-PARTY-NOTICES.txt" !insertmacro DeleteInstalledFile "bin\pathman.exe" diff --git a/packaging/native/CMakeLists.txt b/packaging/native/CMakeLists.txt index 4804c30..cc58956 100644 --- a/packaging/native/CMakeLists.txt +++ b/packaging/native/CMakeLists.txt @@ -26,6 +26,7 @@ if(BUILD_TESTING) endif() add_test(NAME setup-path COMMAND PathMan.SetupPathTests) add_executable(PathMan.SetupWorkerTests ../../tests/setup-worker.cpp) + target_link_libraries(PathMan.SetupWorkerTests PRIVATE advapi32) target_compile_features(PathMan.SetupWorkerTests PRIVATE cxx_std_20) target_compile_definitions(PathMan.SetupWorkerTests PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX) set_target_properties(PathMan.SetupWorkerTests PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") diff --git a/packaging/native/actions.cpp b/packaging/native/actions.cpp index 9abc408..cc63d0e 100644 --- a/packaging/native/actions.cpp +++ b/packaging/native/actions.cpp @@ -12,6 +12,8 @@ #include #include "version.h" #include "verified-worker.h" +#include "protected-source.h" +#include #ifdef _M_IX86 #pragma comment(linker, "/EXPORT:CompareVersions=_CompareVersions@8") @@ -31,6 +33,8 @@ #pragma comment(linker, "/EXPORT:BeginSetup=_BeginSetup@0") #pragma comment(linker, "/EXPORT:CleanupUserInstall=_CleanupUserInstall@8") #pragma comment(linker, "/EXPORT:PrepareOriginBroker=_PrepareOriginBroker@12") +#pragma comment(linker, "/EXPORT:RemoveOriginTasks=_RemoveOriginTasks@8") +#pragma comment(linker, "/EXPORT:MaintainOriginTask=_MaintainOriginTask@16") #endif extern "C" __declspec(dllexport) DWORD __stdcall CompareVersions(const wchar_t* left,const wchar_t* right) { @@ -470,6 +474,43 @@ DWORD run_verified_elevated(HWND owner, const std::filesystem::path& worker, con return result; } } +namespace { +DWORD origin_maintenance(const wchar_t* directory, const std::wstring& parameters, BOOL machine) { + try { + const auto executable = std::filesystem::path(directory) / L"internal" / L"PathMan.Service.exe"; + if (!std::filesystem::exists(executable)) return ERROR_SUCCESS; + std::unique_ptr source; + if (machine) { + source = std::make_unique(std::filesystem::path(directory)); + if (source->error()) return source->error(); + } + auto command = L"\"" + executable.wstring() + L"\" " + parameters; + STARTUPINFOW start{sizeof(start)}; PROCESS_INFORMATION process{}; + if (!CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, directory, &start, &process)) return GetLastError(); + wait_with_messages(process.hProcess); + DWORD result = ERROR_INSTALL_FAILURE; + GetExitCodeProcess(process.hProcess, &result); + CloseHandle(process.hThread); CloseHandle(process.hProcess); + if (result != ERROR_ELEVATION_REQUIRED) return result; + SHELLEXECUTEINFOW execute{sizeof(execute)}; + execute.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI; + execute.lpVerb = L"runas"; execute.lpFile = executable.c_str(); execute.lpParameters = parameters.c_str(); execute.nShow = SW_HIDE; + if (!ShellExecuteExW(&execute)) return GetLastError(); + if (!execute.hProcess) return ERROR_INSTALL_FAILURE; + wait_with_messages(execute.hProcess); + GetExitCodeProcess(execute.hProcess, &result); CloseHandle(execute.hProcess); + return result; + } catch (...) { return ERROR_INSTALL_FAILURE; } +} +} +extern "C" __declspec(dllexport) DWORD __stdcall RemoveOriginTasks(const wchar_t* directory, BOOL machine) { + return origin_maintenance(directory, L"--remove-origin-tasks", machine); +} +extern "C" __declspec(dllexport) DWORD __stdcall MaintainOriginTask(const wchar_t* directory, const wchar_t* localData, const wchar_t* originalSid, BOOL machine) { + const auto sid = originalSid && *originalSid ? std::wstring(originalSid) : current_sid(); + const auto data = std::filesystem::path(localData) / L"PathMan"; + return origin_maintenance(directory, L"--maintain-origin-task \"" + sid + L"\" \"" + data.wstring() + L"\"", machine); +} extern "C" __declspec(dllexport) DWORD __stdcall PrepareOriginBroker(const wchar_t* directory, const wchar_t* localData, const wchar_t* originalSid) { try { const auto sid = originalSid && *originalSid ? std::wstring(originalSid) : current_sid(); diff --git a/packaging/native/protected-source.h b/packaging/native/protected-source.h new file mode 100644 index 0000000..1a74cbc --- /dev/null +++ b/packaging/native/protected-source.h @@ -0,0 +1,69 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace pathman_setup { +inline bool trusted_owner(PSID sid) { + if (!sid) return false; + if (IsWellKnownSid(sid, WinLocalSystemSid) || IsWellKnownSid(sid, WinBuiltinAdministratorsSid)) return true; + std::array installer{}; + wchar_t domain[256]{}; DWORD bytes = static_cast(installer.size()), length = 256; SID_NAME_USE use{}; + return LookupAccountNameW(nullptr, L"NT SERVICE\\TrustedInstaller", installer.data(), &bytes, domain, &length, &use) && EqualSid(sid, installer.data()); +} +inline bool protected_descriptor(PSECURITY_DESCRIPTOR security, bool payload) { + PSID owner = nullptr; BOOL defaulted = FALSE, present = FALSE; PACL acl = nullptr; + if (!GetSecurityDescriptorOwner(security, &owner, &defaulted) || !trusted_owner(owner) || + !GetSecurityDescriptorDacl(security, &present, &acl, &defaulted) || !present || !acl) return false; + const DWORD dangerous = 0x500d0152u | (payload ? 4u : 0u); + for (DWORD index = 0; index < acl->AceCount; ++index) { + void* entry = nullptr; if (!GetAce(acl, index, &entry)) return false; + const auto header = static_cast(entry); + if (header->AceFlags & INHERIT_ONLY_ACE) continue; + if (header->AceType == ACCESS_DENIED_ACE_TYPE) continue; + if (header->AceType != ACCESS_ALLOWED_ACE_TYPE) return false; + const auto allow = static_cast(entry); + if ((allow->Mask & dangerous) && !trusted_owner(&allow->SidStart)) return false; + } + return true; +} +class protected_source { + std::vector handles_; + DWORD error_ = ERROR_SUCCESS; + bool open(const std::filesystem::path& path, bool directory, bool payload) { + const auto handle = CreateFileW(path.c_str(), directory ? READ_CONTROL | FILE_READ_ATTRIBUTES : GENERIC_READ | READ_CONTROL, + directory ? FILE_SHARE_READ | FILE_SHARE_WRITE : FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | (directory ? FILE_FLAG_BACKUP_SEMANTICS : 0), nullptr); + if (handle == INVALID_HANDLE_VALUE) { error_ = GetLastError(); return false; } + handles_.push_back(handle); + BY_HANDLE_FILE_INFORMATION info{}; + if (!GetFileInformationByHandle(handle, &info)) { error_ = GetLastError(); return false; } + if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { error_ = ERROR_ACCESS_DENIED; return false; } + PSID owner = nullptr; PACL acl = nullptr; PSECURITY_DESCRIPTOR security = nullptr; + error_ = GetSecurityInfo(handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, nullptr, &acl, nullptr, &security); + if (error_) return false; + const bool valid = protected_descriptor(security, payload); LocalFree(security); + if (!valid) error_ = ERROR_ACCESS_DENIED; + return valid; + } +public: + explicit protected_source(const std::filesystem::path& installation) { + const auto root = installation.root_path(); + if (!installation.is_absolute() || GetDriveTypeW(root.c_str()) != DRIVE_FIXED) { error_ = ERROR_ACCESS_DENIED; return; } + std::vector paths; + for (auto path = installation / L"internal";; path = path.parent_path()) { + paths.push_back(path); if (path == root) break; + if (path.empty() || path == path.parent_path()) { error_ = ERROR_BAD_PATHNAME; return; } + } + for (auto it = paths.rbegin(); it != paths.rend(); ++it) + if (!open(*it, true, *it == installation || *it == installation / L"internal")) return; + open(installation / L"internal" / L"PathMan.Service.exe", false, true); + } + ~protected_source() { for (const auto handle : handles_) CloseHandle(handle); } + protected_source(const protected_source&) = delete; + protected_source& operator=(const protected_source&) = delete; + DWORD error() const { return error_; } +}; +} diff --git a/scripts/build-matrix.ps1 b/scripts/build-matrix.ps1 index 999c605..3d8297e 100644 --- a/scripts/build-matrix.ps1 +++ b/scripts/build-matrix.ps1 @@ -86,6 +86,7 @@ foreach ($cpu in $Arch) { '-p:DebugType=None', '-o', $project.Destination)+$versionProperties) } Copy-Item -LiteralPath (Join-Path $nativeBuild "$Configuration/PathMan.Shim.exe") -Destination (Join-Path $output 'internal/PathMan.Shim.exe') + @{sha256=(Get-FileHash -LiteralPath (Join-Path $output 'internal/PathMan.Service.exe') -Algorithm SHA256).Hash} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $output 'internal/service-payload.json') -Encoding utf8 Copy-Item -LiteralPath (Join-Path $projectRoot 'LICENSE') -Destination $output Copy-Item -LiteralPath (Join-Path $projectRoot 'THIRD-PARTY-NOTICES.txt') -Destination $output Copy-Item -LiteralPath (Join-Path $projectRoot 'licenses') -Destination $output -Recurse diff --git a/scripts/test.ps1 b/scripts/test.ps1 index 0197004..a250322 100644 --- a/scripts/test.ps1 +++ b/scripts/test.ps1 @@ -83,6 +83,7 @@ try { $layout=[Pathman.Core.PathmanLayout]::new($installation,$dataRoot) $manager=[Pathman.Core.Shims.ShimManager]::new($layout,$false) $background=[Pathman.Core.BackgroundProcess]::new($layout) + $unified=[Pathman.Core.GlobalMonitoring]::new($layout).IsEnabled $name = "p'w'sh-中文" $null = $manager.Add($name,$pwsh) $alias = Join-Path $dataRoot "shims/$name.exe" @@ -147,13 +148,13 @@ try { Assert (!(Test-Path -LiteralPath (Join-Path $dataRoot "shims/$name.shim"))) 'Removing an alias left its manifest behind.' $missing = Invoke-Program $template @() Assert ($missing.Code -eq 13) 'A missing manifest should fail clearly.' - Assert-Rejected {$background.Start()} + if($unified){$background.Start()}else{Assert-Rejected {$background.Start()}} Assert (!$background.IsRunning) 'Disabled background must remain stopped.' $duplicate = Invoke-Program (Join-Path $installation 'internal/PathMan.Service.exe') @('--data-root', $dataRoot) - Assert ($duplicate.Code -eq 5) 'A direct disabled background launch must also be refused.' + Assert ($duplicate.Code -eq $(if($unified){0}else{5})) 'A direct launch must leave the personal background stopped when disabled or owned by the unified service.' $background.Stop() Assert (!$background.IsRunning) 'Background process did not stop.' - Assert-Rejected {$background.Start()} + if($unified){$background.Start()}else{Assert-Rejected {$background.Start()}} $background.Stop() if ([IO.Path]::GetPathRoot($testRoot) -ne [IO.Path]::GetPathRoot([IO.Path]::GetTempPath())) { diff --git a/src/Pathman.Core/BackgroundProcess.cs b/src/Pathman.Core/BackgroundProcess.cs index da3d984..969950e 100644 --- a/src/Pathman.Core/BackgroundProcess.cs +++ b/src/Pathman.Core/BackgroundProcess.cs @@ -8,6 +8,8 @@ namespace Pathman.Core; public sealed class BackgroundProcess(PathmanLayout layout) { + public static Mutex OwnerMutex(string sid,string installation,int? sessionId=null)=>WindowsUserContext.WorkspaceMutex(@"Global\PathMan.MonitorOwner."+sid+".Session."+(sessionId ?? Process.GetCurrentProcess().SessionId)); + public static Mutex WriterMutex(string sid)=>WindowsUserContext.WorkspaceMutex(@"Global\PathMan.MonitorWriter."+sid); // Per-user, per-installation names prevent another installation from stopping this process. public string InstanceName { @@ -32,6 +34,7 @@ public bool IsRunning public void Start() { + if(new GlobalMonitoring(layout).IsEnabled) return; if (IsRunning) return; var state=new EnvironmentWorkspaceStore(layout.DataDirectory).Read(); if(!StartupManager.CanRun(state,new StartupManager(layout).IsEnabled)) diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs index ceb2330..dd9656a 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentMonitor.cs @@ -7,7 +7,7 @@ namespace Pathman.Core.EnvironmentVariables; -public sealed class EnvironmentMonitor(EnvironmentWorkspaceStore store) +public sealed class EnvironmentMonitor(EnvironmentWorkspaceStore store,VariableScope? managedScope=null,IReadOnlyDictionary? runtime=null) { private readonly HashSet failedPlans=[]; public static IReadOnlyList ReadAll() @@ -15,31 +15,33 @@ public static IReadOnlyList ReadAll() var reader = new WindowsEnvironmentVariableReader(); return reader.Read(VariableScope.User).Concat(reader.Read(VariableScope.Machine)).ToArray(); } - public void SetEnabled(bool enabled) => store.Update(state => + private IReadOnlyList ReadCurrent()=>managedScope==VariableScope.Machine ? new WindowsEnvironmentVariableReader().Read(VariableScope.Machine) : ReadAll(); + public void SetEnabled(bool enabled,IReadOnlyList? captured=null) => store.Update(state => { state.MonitoringEnabled = enabled; - if (enabled) state.Snapshot = ReadAll().Select(Snapshot).ToList(); + if (enabled) state.Snapshot = (captured ?? ReadCurrent()).Select(Snapshot).ToList(); }); - public void Poll(IEnvironmentChangeOrigins? origins=null) + public bool Poll(IEnvironmentChangeOrigins? origins=null,IReadOnlyList? captured=null) { var paused=false; store.Inspect(state=> { if(!state.MonitoringEnabled) return false; if(state.RegistryWriteInProgressUntil>DateTime.UtcNow) {paused=true;return false;} - var current=ReadAll(); + var current=captured ?? ReadCurrent(); var before=state.Snapshot.ToArray(); var changed=Compare(state,current,origins); - return PreparePlans(state,before,current,changed,origins:origins) || changed; + return PreparePlans(state,before,current,changed,runtime:runtime,origins:origins,managedScope:managedScope) || changed; }); - if(paused) return; + if(paused) return false; var saved=store.Read(); - if(!saved.MonitoringEnabled || !saved.AnyPathOrganizationEnabled) return; + if(!saved.MonitoringEnabled || !saved.AnyPathOrganizationEnabled) return false; + var applied=false; using var identity=WindowsIdentity.GetCurrent(); var administrator=new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); foreach(var plan in saved.PendingPathOrganizations) { - if(!saved.IsPathOrganizationEnabled(plan.Scope) || RequiresOrigin(saved,plan.Scope) && origins?.IsReady!=true) continue; + if(managedScope is {} selected && plan.Scope!=selected || !saved.IsPathOrganizationEnabled(plan.Scope) || RequiresOrigin(saved,plan.Scope) && origins?.IsReady!=true) continue; if(failedPlans.Contains(plan.Id)) continue; if(plan.Scope==VariableScope.Machine && !administrator) continue; try @@ -51,21 +53,23 @@ public void Poll(IEnvironmentChangeOrigins? origins=null) throw new IOException(L.Get("监控设置或待整理项目已改变,本次整理已跳过。")); }); Complete(store,plan); + applied=true; } catch(Exception error) when(error is IOException or UnauthorizedAccessException or System.Security.SecurityException or ArgumentException) {failedPlans.Add(plan.Id);ReportError(L.Get("自动整理未完成:")+error.Message);} } failedPlans.RemoveWhere(id=>!saved.PendingPathOrganizations.Any(plan=>plan.Id==id)); + return applied; } internal static bool PreparePlans(EnvironmentWorkspace state,IReadOnlyList before, - IReadOnlyList current,bool changed,IReadOnlyDictionary? runtime=null,IEnvironmentChangeOrigins? origins=null) + IReadOnlyList current,bool changed,IReadOnlyDictionary? runtime=null,IEnvironmentChangeOrigins? origins=null,VariableScope? managedScope=null) { if(!state.AnyPathOrganizationEnabled) {var had=state.PendingPathOrganizations.Count>0;state.PendingPathOrganizations.Clear();return had;} var updated=false; foreach(var scope in new[] {VariableScope.User,VariableScope.Machine}) { - if(!state.IsPathOrganizationEnabled(scope)) + if(managedScope is {} selected && scope!=selected || !state.IsPathOrganizationEnabled(scope)) {updated|=state.PendingPathOrganizations.RemoveAll(item=>item.Scope==scope)>0;continue;} var pending=state.PendingPathOrganizations.FirstOrDefault(item=>item.Scope==scope); var old=before.FirstOrDefault(item=>item.Scope==scope && item.Name.Equals("Path",StringComparison.OrdinalIgnoreCase)); diff --git a/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs b/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs index 9dd030b..bae9ec7 100644 --- a/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs +++ b/src/Pathman.Core/EnvironmentVariables/EnvironmentWorkspace.cs @@ -171,14 +171,20 @@ public sealed class EnvironmentWorkspaceStore public EnvironmentWorkspaceStore(string dataDirectory) { FilePath = Path.Combine(Path.GetFullPath(dataDirectory), "environment.json"); - mutexName = @"Local\PathMan.Environment." + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(FilePath.ToUpperInvariant())))[..24]; + mutexName = @"Global\PathMan.Environment." + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(FilePath.ToUpperInvariant())))[..24]; } internal static T ReadDefault(string name) => JsonSerializer.Deserialize(typeof(EnvironmentWorkspaceStore).Assembly .GetManifestResourceStream($"Pathman.Core.EnvironmentVariables.Defaults.{name}.json")!, JsonOptions)!; private EnvironmentWorkspace Load() { - var state = File.Exists(FilePath) ? JsonSerializer.Deserialize(File.ReadAllText(FilePath), JsonOptions) - ?? throw new InvalidDataException(L.Get("环境变量配置为空。")) : ReadDefault("groups"); + EnvironmentWorkspace state; + if(File.Exists(FilePath)) + { + // Global history readers do not hold the service's writer mutex. + using var stream=File.Open(FilePath,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete); + state=JsonSerializer.Deserialize(stream,JsonOptions) ?? throw new InvalidDataException(L.Get("环境变量配置为空。")); + } + else state=ReadDefault("groups"); state.Preferences??=new();state.Preferences.Language??="system";state.Preferences.Theme??="system"; foreach(var project in state.VersionProjects) project.Versions=new(project.Versions,StringComparer.OrdinalIgnoreCase); VersionCatalog.Validate(state.VersionProjects); @@ -203,7 +209,7 @@ private EnvironmentWorkspace Load() } private T Locked(Func action) { - using var mutex = new Mutex(false, mutexName); + using var mutex = WindowsUserContext.WorkspaceMutex(mutexName); try { if (!mutex.WaitOne(TimeSpan.FromSeconds(15))) throw new IOException(L.Get("环境变量配置正在使用中,请稍后重试。")); } catch (AbandonedMutexException) { } try { return action(); } finally { mutex.ReleaseMutex(); } @@ -212,6 +218,7 @@ public EnvironmentWorkspace Read() => Locked(()=> { var state=Load();if(EnvironmentMonitorNotices.Reconcile(state)) Save(state);return state; }); + public EnvironmentWorkspace ReadSnapshot()=>Load(); public void Update(Action change) => Locked(() => { var state = Load(); change(state); EnvironmentMonitorNotices.Reconcile(state); Save(state); return true; @@ -220,6 +227,7 @@ internal void Inspect(Func change) => Locked(() => { var state = Load(); var reconciled=EnvironmentMonitorNotices.Reconcile(state); if (change(state) || reconciled) Save(state); return true; }); + public void InspectForService(Func change)=>Inspect(change); private void Save(EnvironmentWorkspace state) { state.Revision = Guid.NewGuid().ToString("N"); diff --git a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs index 5827658..3b1baed 100644 --- a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs +++ b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableEditor.cs @@ -32,7 +32,7 @@ public static void Apply(EnvironmentMutation mutation, EnvironmentWorkspaceStore if (mutation.Scope is not (VariableScope.User or VariableScope.Machine)) throw new ArgumentOutOfRangeException(nameof(mutation)); store.Update(state => { - using var key = mutation.Scope == VariableScope.User ? Registry.CurrentUser.CreateSubKey("Environment", writable: true) : + using var key = mutation.Scope == VariableScope.User ? WindowsUserContext.EnvironmentKey(true) ?? throw new IOException("Cannot open user environment.") : Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", writable: true) ?? throw new IOException(L.Get("找不到系统环境变量注册表项。")); ApplyKey(key, mutation); @@ -54,7 +54,7 @@ public static void ApplyBatch(IReadOnlyList mutations, IRea { var writable = requiredScope == scope; keys[requiredScope] = requiredScope == VariableScope.User - ? Registry.CurrentUser.CreateSubKey("Environment", writable) + ? WindowsUserContext.EnvironmentKey(writable) ?? throw new IOException("Cannot open user environment.") : Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", writable) ?? throw new IOException(L.Get("找不到环境变量注册表项。")); } @@ -187,4 +187,5 @@ private static void RecordOwnChange(EnvironmentWorkspace state, EnvironmentMutat [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam, string lParam, uint flags, uint timeout, out UIntPtr result); private static void Broadcast() => SendMessageTimeout(new IntPtr(0xffff), 0x1a, UIntPtr.Zero, "Environment", 2, 200, out _); + public static void NotifyEnvironmentChanged()=>Broadcast(); } diff --git a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableReader.cs b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableReader.cs index f00b3fe..cd693c3 100644 --- a/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableReader.cs +++ b/src/Pathman.Core/EnvironmentVariables/WindowsEnvironmentVariableReader.cs @@ -11,7 +11,7 @@ public IReadOnlyList Read(VariableScope scope) // environment inherited by this process. Reading needs no elevation. using var key = scope switch { - VariableScope.User => Registry.CurrentUser.OpenSubKey("Environment", writable: false), + VariableScope.User => WindowsUserContext.EnvironmentKey(false), VariableScope.Machine => Registry.LocalMachine.OpenSubKey( @"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", writable: false), _ => throw new ArgumentOutOfRangeException(nameof(scope)) diff --git a/src/Pathman.Core/GlobalMonitoring.cs b/src/Pathman.Core/GlobalMonitoring.cs new file mode 100644 index 0000000..b8ca98f --- /dev/null +++ b/src/Pathman.Core/GlobalMonitoring.cs @@ -0,0 +1,204 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Security.Principal; +using System.Security.AccessControl; +using System.Text; +using System.Text.Json; +using System.Runtime.InteropServices; +using Pathman.Core.EnvironmentVariables; + +namespace Pathman.Core; + +public sealed record GlobalMonitorSettings(bool Enabled=false,bool ProvideNonAdministratorOrigins=false,bool AutoArrangeMachinePath=false,string InstallationDirectory="") +{ + public bool RemoveMachinePathDuplicates {get;init;}=true; + public bool MoveExternalMachinePathEntries {get;init;}=true; + public bool IgnoreSystemEditorMachineChanges {get;init;} + public bool IgnorePowerToysMachineChanges {get;init;} + public bool ApplyMachineOptions(EnvironmentWorkspace state) + { + var changed=!state.MonitoringEnabled || !state.TrackChangeOrigins || state.AutoArrangeMachinePath!=AutoArrangeMachinePath || + state.RemoveMachinePathDuplicates!=RemoveMachinePathDuplicates || state.MoveExternalMachinePathEntries!=MoveExternalMachinePathEntries || + state.IgnoreSystemEditorMachineChanges!=IgnoreSystemEditorMachineChanges || state.IgnorePowerToysMachineChanges!=IgnorePowerToysMachineChanges; + state.MonitoringEnabled=true;state.TrackChangeOrigins=true;state.AutoArrangeMachinePath=AutoArrangeMachinePath; + state.RemoveMachinePathDuplicates=RemoveMachinePathDuplicates;state.MoveExternalMachinePathEntries=MoveExternalMachinePathEntries; + state.IgnoreSystemEditorMachineChanges=IgnoreSystemEditorMachineChanges;state.IgnorePowerToysMachineChanges=IgnorePowerToysMachineChanges; + return changed; + } +} + +public sealed class GlobalMonitoring(PathmanLayout layout) +{ + public const string ServiceName="PathMan.Monitor"; + public static string DataDirectory=>Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),"PathMan","monitor"); + public static string SettingsPath=>Path.Combine(DataDirectory,"settings.json"); + public static string MachineDirectory=>Path.Combine(DataDirectory,"machine"); + public static string StatusPath=>Path.Combine(DataDirectory,"status.json"); + public bool IsAllUsers + { + get + { + var file=Path.Combine(layout.InternalDirectory,"install.ini"); + return File.Exists(file) && File.ReadLines(file).Any(line=>line.Trim().Equals("Scope=AllUsers",StringComparison.OrdinalIgnoreCase)); + } + } + public GlobalMonitorSettings Read() + { + var settings=ReadSettings(); + return settings.InstallationDirectory.Equals(layout.InstallationDirectory,StringComparison.OrdinalIgnoreCase) ? settings : new(); + } + public static GlobalMonitorSettings ReadSettings() + { + if(!File.Exists(SettingsPath)) return new(); + using var stream=File.Open(SettingsPath,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete); + return JsonSerializer.Deserialize(stream,EnvironmentWorkspaceStore.JsonOptions) ?? new(); + } + public bool IsEnabled=>ReadSettings().Enabled; + public void Configure(GlobalMonitorSettings settings) + { + if(!IsAllUsers) throw new IOException(L.Get("全局监控仅支持为所有用户安装。")); + using var source=PrivilegedServiceSource.Open(layout); + var start=new ProcessStartInfo(layout.BackgroundExecutable) {UseShellExecute=true,Verb="runas",WindowStyle=ProcessWindowStyle.Hidden}; + start.ArgumentList.Add("--configure-global-monitor"); + foreach(var value in new[]{settings.Enabled,settings.ProvideNonAdministratorOrigins,settings.AutoArrangeMachinePath,settings.RemoveMachinePathDuplicates,settings.MoveExternalMachinePathEntries,settings.IgnoreSystemEditorMachineChanges,settings.IgnorePowerToysMachineChanges}) start.ArgumentList.Add(value.ToString()); + using var process=Process.Start(start) ?? throw new IOException(L.Get("无法配置全局监控服务。")); + if(!process.WaitForExit(60000) || process.ExitCode!=0) throw new IOException(L.Get("无法配置全局监控服务。")); + } + private string ProtectedDirectory=>Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),"PathMan Monitor"); + public void ConfigureElevated(bool enabled,bool provideOrigins,bool organize,bool? removeDuplicates=null,bool? moveEntries=null,bool? ignoreSystemEditor=null,bool? ignorePowerToys=null) + { + DemandAdministrator(); + using var mutex=WindowsUserContext.WorkspaceMutex(@"Global\PathMan.GlobalMonitor.Configuration"); + try {if(!mutex.WaitOne(60000)) throw new IOException("Global configuration is busy.");}catch(AbandonedMutexException) { } + try {ConfigureLocked(enabled,provideOrigins,organize,removeDuplicates,moveEntries,ignoreSystemEditor,ignorePowerToys);}finally {mutex.ReleaseMutex();} + } + private void ConfigureLocked(bool enabled,bool provideOrigins,bool organize,bool? removeDuplicates,bool? moveEntries,bool? ignoreSystemEditor,bool? ignorePowerToys) + { + if(!IsAllUsers) throw new IOException(L.Get("全局监控仅支持为所有用户安装。")); + using var source=enabled ? PrivilegedServiceSource.Open(layout) : null; + source?.ValidateDestination(ProtectedDirectory); + OriginTrackingTask.SecureDirectory(Path.GetDirectoryName(DataDirectory)!); + OriginTrackingTask.SecureDirectory(DataDirectory); + OriginTrackingTask.SecureDirectory(MachineDirectory); + SecureExistingData(DataDirectory); + var previous=ReadSettings(); + if(previous.Enabled && !previous.InstallationDirectory.Equals(layout.InstallationDirectory,StringComparison.OrdinalIgnoreCase)) throw new IOException("Another installation owns global monitoring."); + var updated=previous with {Enabled=enabled,ProvideNonAdministratorOrigins=provideOrigins,AutoArrangeMachinePath=organize,InstallationDirectory=layout.InstallationDirectory, + RemoveMachinePathDuplicates=removeDuplicates ?? previous.RemoveMachinePathDuplicates,MoveExternalMachinePathEntries=moveEntries ?? previous.MoveExternalMachinePathEntries, + IgnoreSystemEditorMachineChanges=ignoreSystemEditor ?? previous.IgnoreSystemEditorMachineChanges,IgnorePowerToysMachineChanges=ignorePowerToys ?? previous.IgnorePowerToysMachineChanges}; + if(enabled) + { + var executable=Path.Combine(ProtectedDirectory,"PathMan.Service.exe"); + Stop();source!.Deploy(ProtectedDirectory); + var command=$"\"{executable}\" --system-monitor"; + var exists=Sc("query",ServiceName)==0; + Checked(exists ? "config" : "create",ServiceName,"binPath=",command,"start=","auto","obj=","LocalSystem","DisplayName=","PathMan Monitor"); + // Restart automatically after a crash; an ordinary user cannot edit the service. + Checked("failure",ServiceName,"reset=","86400","actions=","restart/5000/restart/15000/restart/60000"); + Checked("failureflag",ServiceName,"1"); + } + WriteSettings(updated); + try + { + if(enabled) {var result=Sc("start",ServiceName);if(result is not (0 or 1056)) throw new Win32Exception(result);} + else {Stop();if(Sc("query",ServiceName)==0) Checked("config",ServiceName,"start=","demand");} + } + catch {WriteSettings(previous);throw;} + } + public void Maintain() + { + var state=Read(); + if(state.Enabled) ConfigureElevated(true,state.ProvideNonAdministratorOrigins,state.AutoArrangeMachinePath); + } + public void Remove() + { + var state=ReadSettings(); + if(!state.InstallationDirectory.Equals(layout.InstallationDirectory,StringComparison.OrdinalIgnoreCase)) return; + DemandAdministrator();Stop(); + if(Sc("query",ServiceName)==0) Checked("delete",ServiceName); + WriteSettings(state with {Enabled=false}); + // Delete only the fixed, protected executable directory owned by this service. + if(Directory.Exists(ProtectedDirectory)) + { + if((File.GetAttributes(ProtectedDirectory)&FileAttributes.ReparsePoint)!=0) throw new IOException("Invalid service cleanup directory."); + OriginTrackingTask.DeleteStoppedDirectory(ProtectedDirectory); + } + } + public static void Stop() + { + var result=Sc("stop",ServiceName); + if(result is not (0 or 1060 or 1062)) throw new Win32Exception(result); + for(var retry=0;retry<150;retry++) + { + var status=ScOutput("query",ServiceName); + if(status.Code==1060 || status.Output.Contains("STOPPED",StringComparison.Ordinal)) return; + Thread.Sleep(100); + } + throw new IOException("Global monitor did not stop."); + } + public static string? GetUnavailableMessage() + { + if(!ReadSettings().Enabled) return null; + var manager=OpenSCManager(null,null,1); + if(manager==IntPtr.Zero) return L.Get("无法查询全局监控服务状态。"); + try + { + var service=OpenService(manager,ServiceName,4); + if(service==IntPtr.Zero) return L.Get("全局监控已启用,但服务未运行。请由管理员检查或重新启用全局设置。"); + try + { + if(!QueryServiceStatusEx(service,0,out var status,Marshal.SizeOf(),out _)) return L.Get("无法查询全局监控服务状态。"); + if(status.State==2) return L.Get("全局监控服务正在启动或恢复,请稍候。"); + if(status.State!=4) return L.Get("全局监控已启用,但服务未运行。请由管理员检查或重新启用全局设置。"); + using var stream=File.Open(StatusPath,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete); + using var document=JsonDocument.Parse(stream);var root=document.RootElement; + if(!root.GetProperty("Running").GetBoolean() || root.GetProperty("Time").GetDateTime()ScOutput(arguments).Code; + private static void Checked(params string[] arguments) {var result=ScOutput(arguments);if(result.Code!=0) throw new IOException(result.Output);} + private static (int Code,string Output) ScOutput(params string[] arguments) + { + var start=new ProcessStartInfo(Path.Combine(Environment.SystemDirectory,"sc.exe")) {UseShellExecute=false,CreateNoWindow=true,RedirectStandardOutput=true,RedirectStandardError=true}; + foreach(var argument in arguments) start.ArgumentList.Add(argument); + using var process=Process.Start(start) ?? throw new IOException("Cannot start the service controller."); + var output=process.StandardOutput.ReadToEndAsync();var error=process.StandardError.ReadToEndAsync(); + if(!process.WaitForExit(20000)) throw new IOException("Service controller timeout."); + return (process.ExitCode,output.GetAwaiter().GetResult()+error.GetAwaiter().GetResult()); + } + private static void DemandAdministrator() {if(!WindowsUserContext.IsElevatedAdministrator()) throw new Win32Exception(740);} +} diff --git a/src/Pathman.Core/Localization/Strings.resx b/src/Pathman.Core/Localization/Strings.resx index 26e93d5..2e4ed01 100644 --- a/src/Pathman.Core/Localization/Strings.resx +++ b/src/Pathman.Core/Localization/Strings.resx @@ -982,8 +982,26 @@ The administrator connection timed out. Reload to confirm the result before retrying. - - Identify the writing process precisely (requires administrator privileges) + + Identify the writing process precisely + + + Requires administrator privileges + + + Requires precise identification of the writing process + + + Cannot access the origin tracking scheduled task. + + + Cannot register the origin tracking scheduled task. + + + Cannot start the origin tracking scheduled task. + + + Turn precise origin tracking off and on to approve its one-time administrator setup. System @@ -1215,4 +1233,16 @@ Go to version management This feature adds the command-line directory to PATH Receive prerelease updates + Global settings + Enable unified settings for all users + The per-user “Automatically organize the system PATH” option will be unavailable + Provide precise change origins for non-administrator users + Automatically organize the system PATH + Global monitoring requires an installation for all users. + Cannot configure the global monitoring service. + The global monitoring service is starting or recovering. Please wait. + The global monitoring service is not responding. Ask an administrator to check it or enable global settings again. + The global service requires an administrator-protected local installation directory. Reinstall PathMan in a secure directory. + Cannot query the global monitoring service status. + Global monitoring is enabled, but its service is not running. Ask an administrator to check it or enable global settings again. diff --git a/src/Pathman.Core/Localization/Strings.zh-CN.resx b/src/Pathman.Core/Localization/Strings.zh-CN.resx index 4197cf4..4e408ca 100644 --- a/src/Pathman.Core/Localization/Strings.zh-CN.resx +++ b/src/Pathman.Core/Localization/Strings.zh-CN.resx @@ -982,8 +982,26 @@ 管理员连接超时,请刷新确认写入结果后重试。 - - 精确识别修改来源(需要管理员权限) + + 精确识别修改来源 + + + 需要管理员权限 + + + 此功能需要开启精确识别修改来源 + + + 无法访问来源识别计划任务。 + + + 无法注册来源识别计划任务。 + + + 无法启动来源识别计划任务。 + + + 请重新开启“精确识别修改来源”,完成一次管理员授权。 系统 @@ -1215,4 +1233,16 @@ 前往版本管理 此功能将在PATH中添加命令行目录 接收测试版更新 + 全局设置 + 为所有用户开启统一设置 + 用户级的“自动整理系统PATH环境变量”将不可用 + 为非管理员用户提供精确修改来源 + 自动整理系统PATH环境变量 + 全局监控仅支持为所有用户安装。 + 无法配置全局监控服务。 + 全局监控服务正在启动或恢复,请稍候。 + 全局监控服务未正常响应,请由管理员检查或重新启用全局设置。 + 全局服务只能从管理员保护的本地安装目录部署,请重新安装到安全目录。 + 无法查询全局监控服务状态。 + 全局监控已启用,但服务未运行。请由管理员检查或重新启用全局设置。 diff --git a/src/Pathman.Core/OriginTrackingTask.cs b/src/Pathman.Core/OriginTrackingTask.cs new file mode 100644 index 0000000..e27adac --- /dev/null +++ b/src/Pathman.Core/OriginTrackingTask.cs @@ -0,0 +1,154 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using System.Xml.Linq; + +namespace Pathman.Core; + +// An administrator's own collector uses their elevated interactive token. +// Cross-user authorization uses SYSTEM for the fixed, read-only collector. +public sealed class OriginTrackingTask(PathmanLayout layout) +{ + private string InstallationId => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(layout.BackgroundExecutable.ToUpperInvariant())))[..24]; + private string Prefix => "PathMan.Origins." + InstallationId + "."; + private static string CurrentSid {get {using var user=WindowsIdentity.GetCurrent();return user.User!.Value;}} + private string DirectoryFor(string sid) => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),"PathMan Origin Collectors",InstallationId,sid); + public string Executable => Path.Combine(DirectoryFor(CurrentSid),"PathMan.Service.exe"); + private string ArgumentsFor(string sid) => $"--scheduled-origin-worker \"{layout.BackgroundExecutable}\" {sid} \"$(Arg0)\" \"$(Arg1)\" \"$(Arg2)\" \"$(Arg3)\""; + private static dynamic Connect() + { + dynamic scheduler=Activator.CreateInstance(Type.GetTypeFromProgID("Schedule.Service",throwOnError:true)!)!; + scheduler.Connect();return scheduler.GetFolder(@"\"); + } + private dynamic? Find(dynamic folder,string sid) + { + try {return folder.GetTask(Prefix+sid);} + catch(FileNotFoundException) {return null;} + catch(DirectoryNotFoundException) {return null;} + catch(COMException error) when(error.HResult==unchecked((int)0x80070002)) {return null;} + } + public bool IsRegistered + { + get + { + try + { + dynamic? task=Find(Connect(),CurrentSid); + if(task is null || !task.Enabled) return false; + dynamic definition=task!.Definition; + return ValidPrincipal((string)definition.Principal.UserId,CurrentSid) && definition.Principal.RunLevel==1 && definition.Actions.Count==1 && + string.Equals((string)definition.Actions.Item(1).Path,Executable,StringComparison.OrdinalIgnoreCase) && + definition.Actions.Item(1).Arguments==ArgumentsFor(CurrentSid) && File.Exists(Executable); + } + catch(COMException error) {throw new IOException(L.Get("无法访问来源识别计划任务。"),error);} + } + } + private static bool ValidPrincipal(string principal,string sid) + { + if(principal is "SYSTEM" or "S-1-5-18" || principal==sid) return true; + try {return ((SecurityIdentifier)new NTAccount(principal).Translate(typeof(SecurityIdentifier))).Value==sid;} + catch(IdentityNotMappedException) {return false;} + } + public void EnsureRegistered() + { + if(IsRegistered && FileHash(Executable).SequenceEqual(FileHash(layout.BackgroundExecutable))) return; + using var user=WindowsIdentity.GetCurrent(); + var start=new ProcessStartInfo(layout.BackgroundExecutable) {UseShellExecute=true,Verb="runas",WindowStyle=ProcessWindowStyle.Hidden}; + start.ArgumentList.Add("--register-origin-task");start.ArgumentList.Add(user.User!.Value); + using var process=Process.Start(start) ?? throw new IOException(L.Get("无法注册来源识别计划任务。")); + if(!process.WaitForExit(60000) || process.ExitCode!=0 || !IsRegistered) throw new IOException(L.Get("无法注册来源识别计划任务。")); + } + private static byte[] FileHash(string path) {using var input=File.OpenRead(path);return SHA256.HashData(input);} + public void Start(string[] arguments) + { + if(arguments.Length!=4 || !IsRegistered) throw new IOException(L.Get("请重新开启“精确识别修改来源”,完成一次管理员授权。")); + try {dynamic task=Find(Connect(),CurrentSid)!;task.Run(arguments);} + catch(COMException error) {throw new IOException(L.Get("无法启动来源识别计划任务。"),error);} + } + public void Register(string sid) + { + sid=new SecurityIdentifier(sid).Value; + DemandAdministrator(); + using var identity=WindowsIdentity.GetCurrent();var own=identity.User!.Value==sid; + dynamic folder=Connect();dynamic? old=Find(folder,sid);if(old is not null) StopTask(old); + var target=DirectoryFor(sid); + // Program Files is a trusted anchor even for a per-user installation. + // Do not follow junctions or reuse a directory owned by an ordinary user. + var root=Path.GetDirectoryName(Path.GetDirectoryName(target)!)!; + SecureDirectory(root);SecureDirectory(Path.GetDirectoryName(target)!);SecureDirectory(target); + var source=Path.GetDirectoryName(layout.BackgroundExecutable)!; + foreach(var file in Directory.EnumerateFiles(source)) + { + var name=Path.GetFileName(file);var extension=Path.GetExtension(file); + if(!name.Equals("PathMan.Service.exe",StringComparison.OrdinalIgnoreCase) && extension is not (".dll" or ".json")) continue; + if((File.GetAttributes(file)&FileAttributes.ReparsePoint)!=0) throw new IOException("Collector files cannot be reparse points."); + File.Copy(file,Path.Combine(target,name),overwrite:true); + } + XNamespace ns="http://schemas.microsoft.com/windows/2004/02/mit/task"; + XElement Element(string name,object value)=>new(ns+name,value); + var xml=new XDocument(new XElement(ns+"Task",new XAttribute("version","1.2"), + Element("Principals",new XElement(ns+"Principal",new XAttribute("id","Collector"),Element("UserId",own ? sid : "S-1-5-18"),own ? Element("LogonType","InteractiveToken") : null,Element("RunLevel","HighestAvailable"))), + new XElement(ns+"Settings",Element("MultipleInstancesPolicy","Parallel"),Element("DisallowStartIfOnBatteries","false"),Element("StopIfGoingOnBatteries","false"), + Element("AllowStartOnDemand","true"),Element("Enabled","true"),Element("ExecutionTimeLimit","PT0S")), + new XElement(ns+"Actions",new XAttribute("Context","Collector"),new XElement(ns+"Exec",Element("Command",Path.Combine(target,"PathMan.Service.exe")),Element("Arguments",ArgumentsFor(sid)),Element("WorkingDirectory",target))))); + // The desktop user may read/run the fixed task, but cannot edit it. + folder.RegisterTask(Prefix+sid,xml.ToString(),6,own ? sid : "SYSTEM",null,own ? 3 : 5,$"O:BAG:BAD:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGX;;;{sid})"); + } + internal static void SecureDirectory(string path) + { + var directory=Directory.CreateDirectory(path); + if((directory.Attributes&FileAttributes.ReparsePoint)!=0) throw new IOException("Collector directories cannot be reparse points."); + var security=directory.GetAccessControl();var owner=security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier; + var system=new SecurityIdentifier(WellKnownSidType.LocalSystemSid,null); + var admins=new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid,null); + using var identity=WindowsIdentity.GetCurrent(); + if(owner!=system && owner!=admins && owner!=identity.User) throw new UnauthorizedAccessException("Untrusted collector directory owner."); + security=new DirectorySecurity();security.SetOwner(admins);security.SetAccessRuleProtection(true,false); + foreach(var principal in new[] {system,admins}) security.AddAccessRule(new(principal,FileSystemRights.FullControl,InheritanceFlags.ContainerInherit|InheritanceFlags.ObjectInherit,PropagationFlags.None,AccessControlType.Allow)); + security.AddAccessRule(new(new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid,null),FileSystemRights.ReadAndExecute,InheritanceFlags.ContainerInherit|InheritanceFlags.ObjectInherit,PropagationFlags.None,AccessControlType.Allow)); + directory.SetAccessControl(security); + } + public bool HasInstallationTasks() + { + foreach(dynamic task in Connect().GetTasks(1)) if(((string)task.Name).StartsWith(Prefix,StringComparison.Ordinal)) return true; + return false; + } + public bool HasUserTask(string sid) => Find(Connect(),new SecurityIdentifier(sid).Value) is not null; + public void RemoveInstallation() + { + var directory=Path.GetDirectoryName(DirectoryFor(CurrentSid))!; + if(!HasInstallationTasks() && !Directory.Exists(directory)) return; + DemandAdministrator();dynamic folder=Connect(); + foreach(dynamic task in folder.GetTasks(1)) + if(((string)task.Name).StartsWith(Prefix,StringComparison.Ordinal)) {StopTask(task);folder.DeleteTask(task.Name,0);} + if(Directory.Exists(directory)) + { + if((File.GetAttributes(directory)&FileAttributes.ReparsePoint)!=0) throw new IOException("Invalid collector cleanup directory."); + DeleteStoppedDirectory(directory); + } + } + private static void StopTask(dynamic task) + { + task.Stop(0); + for(var retry=0;retry<100;retry++) {if(task.GetInstances(0).Count==0) return;Thread.Sleep(100);} + throw new IOException("Collector task did not stop."); + } + internal static void DeleteStoppedDirectory(string directory) + { + for(var retry=0;;retry++) + { + try {Directory.Delete(directory,true);return;} + catch(IOException failure) when(retry<100 && (failure.HResult&0xffff) is 5 or 32 or 33) {Thread.Sleep(100);} + catch(UnauthorizedAccessException) when(retry<100) {Thread.Sleep(100);} + } + } + private static void DemandAdministrator() + { + using var identity=WindowsIdentity.GetCurrent(); + if(!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) throw new Win32Exception(740); + } +} diff --git a/src/Pathman.Core/PrivilegedServiceSource.cs b/src/Pathman.Core/PrivilegedServiceSource.cs new file mode 100644 index 0000000..4450a0b --- /dev/null +++ b/src/Pathman.Core/PrivilegedServiceSource.cs @@ -0,0 +1,109 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using Microsoft.Win32.SafeHandles; +using System.Text.Json; + +namespace Pathman.Core; + +// Keep every directory and the source image open until deployment completes. +// The ACL check rejects broad write grants even if another ACE denies them. +public sealed class PrivilegedServiceSource : IDisposable +{ + private readonly List directories=[]; + private FileStream? image; + private string sourcePath=""; + private static readonly HashSet trusted=["S-1-5-18","S-1-5-32-544",TrustedInstaller()]; + private static string TrustedInstaller()=>((SecurityIdentifier)new NTAccount(@"NT SERVICE\TrustedInstaller").Translate(typeof(SecurityIdentifier))).Value; + private PrivilegedServiceSource() { } + public static PrivilegedServiceSource Open(PathmanLayout layout) + { + var result=new PrivilegedServiceSource(); + try + { + var root=Path.GetPathRoot(layout.InstallationDirectory)!; + if(root.StartsWith(@"\\",StringComparison.Ordinal) || new DriveInfo(root).DriveType!=DriveType.Fixed) throw new IOException("A local protected service source is required."); + var paths=new Stack(); + for(var directory=new DirectoryInfo(layout.InternalDirectory);directory is not null;directory=directory.Parent) paths.Push(directory.FullName); + foreach(var directory in paths) + { + var handle=CreateFile(directory,0x20080,3,IntPtr.Zero,3,0x02200000,IntPtr.Zero); + if(handle.IsInvalid) {handle.Dispose();throw new Win32Exception();} + result.directories.Add(handle); + Validate(handle,directory.Equals(Path.TrimEndingDirectorySeparator(layout.InstallationDirectory),StringComparison.OrdinalIgnoreCase) || directory.Equals(layout.InternalDirectory,StringComparison.OrdinalIgnoreCase)); + } + var file=CreateFile(layout.BackgroundExecutable,0x80020080,1,IntPtr.Zero,3,0x00200000,IntPtr.Zero); + if(file.IsInvalid) {file.Dispose();throw new Win32Exception();} + try {Validate(file,true);result.image=new FileStream(file,FileAccess.Read);result.sourcePath=layout.BackgroundExecutable;} + catch {file.Dispose();throw;} + using var manifestHandle=CreateFile(Path.Combine(layout.InternalDirectory,"service-payload.json"),0x80020080,1,IntPtr.Zero,3,0x00200000,IntPtr.Zero); + if(manifestHandle.IsInvalid) throw new Win32Exception(); + Validate(manifestHandle,true); + using var manifestStream=new FileStream(manifestHandle,FileAccess.Read); + using var manifest=JsonDocument.Parse(manifestStream); + var expected=manifest.RootElement.GetProperty("sha256").GetString(); + if(!Convert.ToHexString(SHA256.HashData(result.image)).Equals(expected,StringComparison.OrdinalIgnoreCase)) throw new IOException("Service image does not match its protected payload manifest."); + result.image.Position=0; + return result; + } + catch(Exception error) when(error is IOException or UnauthorizedAccessException or Win32Exception or JsonException or KeyNotFoundException or InvalidOperationException) + {result.Dispose();throw new IOException(L.Get("全局服务只能从管理员保护的本地安装目录部署,请重新安装到安全目录。"),error);} + catch {result.Dispose();throw;} + } + internal static bool IsProtected(RawSecurityDescriptor security,bool payload) + { + if(security.Owner is null || !trusted.Contains(security.Owner.Value) || security.DiscretionaryAcl is null) return false; + // Ancestors may let users create unrelated subdirectories (e.g. C:\). + // They must not let them change this directory or replace existing children. + var dangerous=0x500d0152u | (payload ? 0x4u : 0u); + foreach(GenericAce ace in security.DiscretionaryAcl) + { + if((ace.AceFlags&AceFlags.InheritOnly)!=0) continue; + if(ace is not QualifiedAce rule) return false; + if(rule.AceQualifier==AceQualifier.AccessAllowed && !trusted.Contains(rule.SecurityIdentifier.Value) && ((uint)rule.AccessMask&dangerous)!=0) return false; + } + return true; + } + private static void Validate(SafeFileHandle handle,bool payload) + { + if(!GetFileInformationByHandleEx(handle,9,out var attributes,8)) throw new Win32Exception(); + if((attributes.Attributes&0x400)!=0) throw new IOException("Service source cannot contain reparse points."); + var error=GetSecurityInfo(handle,1,5,out _,out _,out _,out _,out var descriptor); + if(error!=0) throw new Win32Exception((int)error); + try + { + var bytes=new byte[GetSecurityDescriptorLength(descriptor)];Marshal.Copy(descriptor,bytes,0,bytes.Length); + if(!IsProtected(new RawSecurityDescriptor(bytes,0),payload)) throw new IOException("Service source must be owned and writable only by SYSTEM, Administrators or TrustedInstaller."); + } + finally {LocalFree(descriptor);} + } + public void Deploy(string directory) + { + ValidateDestination(directory); + // A clean destination prevents stale sidecar DLLs from entering the service. + OriginTrackingTask.SecureDirectory(directory); + if(Directory.Exists(directory)) OriginTrackingTask.DeleteStoppedDirectory(directory); + OriginTrackingTask.SecureDirectory(directory); + var destination=Path.Combine(directory,"PathMan.Service.exe"); + image!.Position=0;var expected=SHA256.HashData(image);image.Position=0; + using(var output=new FileStream(destination,FileMode.CreateNew,FileAccess.ReadWrite,FileShare.None)) + { + image.CopyTo(output);output.Flush(true);output.Position=0; + if(!SHA256.HashData(output).SequenceEqual(expected)) throw new IOException("Service payload verification failed."); + } + } + public void ValidateDestination(string directory) + { + var target=Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory))+Path.DirectorySeparatorChar; + if(sourcePath.StartsWith(target,StringComparison.OrdinalIgnoreCase)) throw new IOException("The service snapshot directory must not contain its installation source."); + } + public void Dispose() {image?.Dispose();foreach(var directory in directories) directory.Dispose();} + [StructLayout(LayoutKind.Sequential)] private struct FileAttributesInfo {public uint Attributes,Tag;} + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,EntryPoint="CreateFileW")] private static extern SafeFileHandle CreateFile(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] private static extern bool GetFileInformationByHandleEx(SafeFileHandle handle,int kind,out FileAttributesInfo attributes,uint length); + [DllImport("advapi32.dll")] private static extern uint GetSecurityInfo(SafeFileHandle handle,int kind,uint information,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor); + [DllImport("advapi32.dll")] private static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + [DllImport("kernel32.dll")] private static extern IntPtr LocalFree(IntPtr memory); +} diff --git a/src/Pathman.Core/StartupManager.cs b/src/Pathman.Core/StartupManager.cs index 5b3da3f..c13ea32 100644 --- a/src/Pathman.Core/StartupManager.cs +++ b/src/Pathman.Core/StartupManager.cs @@ -13,7 +13,7 @@ public bool IsEnabled { get { - using var key = Registry.CurrentUser.OpenSubKey(RunKey); + using var user=WindowsUserContext.OpenRegistry();using var key = user.OpenSubKey(RunKey); return string.Equals(key?.GetValue(ValueName) as string, Command, StringComparison.OrdinalIgnoreCase); } } @@ -24,7 +24,7 @@ public void SetEnabled(bool enabled) throw new IOException(L.Get("请先启用“在后台运行监控程序”。")); if (enabled && !File.Exists(layout.BackgroundExecutable)) throw new FileNotFoundException(L.Get("后台程序不存在,请先构建或安装 PathMan。"), layout.BackgroundExecutable); - using var key = Registry.CurrentUser.CreateSubKey(RunKey, writable: true); + using var user=WindowsUserContext.OpenRegistry(true);using var key = user.CreateSubKey(RunKey, writable: true); if (enabled) key.SetValue(ValueName, Command, RegistryValueKind.String); else if (IsEnabled) key.DeleteValue(ValueName, throwOnMissingValue: false); } diff --git a/src/Pathman.Core/WindowsUserContext.cs b/src/Pathman.Core/WindowsUserContext.cs new file mode 100644 index 0000000..5583220 --- /dev/null +++ b/src/Pathman.Core/WindowsUserContext.cs @@ -0,0 +1,63 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +namespace Pathman.Core; + +public static class WindowsUserContext +{ + public static RegistryKey OpenRegistry(bool writable=false) + { + var error=RegOpenCurrentUser(writable ? 0x2001f : 0x20019,out var key); + if(error!=0) throw new Win32Exception(error); + return RegistryKey.FromHandle(key); + } + public static RegistryKey? EnvironmentKey(bool writable) + {using var user=OpenRegistry(writable);return writable ? user.CreateSubKey("Environment",true) : user.OpenSubKey("Environment");} + public static bool IsElevatedAdministrator() + { + using var identity=WindowsIdentity.GetCurrent(); + return IsElevatedAdministrator(identity); + } + public static bool IsElevatedAdministrator(WindowsIdentity identity) + { + if(!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) return false; + var data=Marshal.AllocHGlobal(256); + try + { + if(!GetTokenInformation(identity.Token,25,data,256,out _)) return false; + var sid=new SecurityIdentifier(Marshal.ReadIntPtr(data)); + return int.Parse(sid.Value.Split('-')[^1],System.Globalization.CultureInfo.InvariantCulture)>=12288; + } + finally {Marshal.FreeHGlobal(data);} + } + public static bool IsAdministratorAccount() + { + using var identity=WindowsIdentity.GetCurrent(); + if(new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) return true; + var data=Marshal.AllocHGlobal(4); + try {return GetTokenInformation(identity.Token,18,data,4,out _) && Marshal.ReadInt32(data) is 2 or 3;} + finally {Marshal.FreeHGlobal(data);} + } + internal static Mutex WorkspaceMutex(string name) + { + using var identity=WindowsIdentity.GetCurrent(); + if(!ConvertStringSecurityDescriptorToSecurityDescriptor($"D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;{identity.User!.Value})S:(ML;;NW;;;ME)",1,out var descriptor,out _)) throw new Win32Exception(); + try + { + var security=new SecurityAttributes {Length=Marshal.SizeOf(),Descriptor=descriptor}; + var handle=CreateMutexEx(ref security,name,0,0x100001); // SYNCHRONIZE | MUTEX_MODIFY_STATE + if(handle.IsInvalid) throw new Win32Exception(); + return new Mutex {SafeWaitHandle=handle}; + } + finally {LocalFree(descriptor);} + } + [StructLayout(LayoutKind.Sequential)] private struct SecurityAttributes {public int Length;public IntPtr Descriptor;public int Inherit;} + [DllImport("advapi32.dll")] private static extern int RegOpenCurrentUser(int access,out SafeRegistryHandle key); + [DllImport("advapi32.dll",SetLastError=true)] private static extern bool GetTokenInformation(IntPtr token,int kind,IntPtr info,int length,out int required); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] private static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor(string text,uint revision,out IntPtr descriptor,out uint size); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,EntryPoint="CreateMutexExW")] private static extern SafeWaitHandle CreateMutexEx(ref SecurityAttributes attributes,string name,uint flags,uint access); + [DllImport("kernel32.dll")] private static extern IntPtr LocalFree(IntPtr memory); +} diff --git a/src/Pathman.Gui/MainWindow.xaml.cs b/src/Pathman.Gui/MainWindow.xaml.cs index 33c7ea4..80c3605 100644 --- a/src/Pathman.Gui/MainWindow.xaml.cs +++ b/src/Pathman.Gui/MainWindow.xaml.cs @@ -39,7 +39,7 @@ public MainWindow() InitializeComponent();Language=System.Windows.Markup.XmlLanguage.GetLanguage(L.Current.CultureName);RefreshWindowTitle(null,EventArgs.Empty);store=new(layout.DataDirectory); updates=new(layout.DataDirectory,release=>UpdateToast.Show(release,layout),store.Read().Preferences.ReceivePrereleaseUpdates); variables=new(layout,store); aliases=new(layout,store); monitor=new(layout,store); about=new(updates,InstallUpdateAsync);settings=new(store); - versions=new(layout,store,InstalledScope()=="/AllUsers" ? Pathman.Core.Models.VariableScope.Machine : Pathman.Core.Models.VariableScope.User);AppVersion.Text=AppInfo.DisplayVersion; + versions=new(layout,store,InstalledScope()=="/AllUsers" ? Pathman.Core.Models.VariableScope.Machine : Pathman.Core.Models.VariableScope.User);AppVersion.Text=AppInfo.Version; settings.PreferencesChanged+=async(_,_)=>await ApplyPreferencesAsync(); PageHost.Content=variables; previousNavigation=Navigation.SelectedItem as ListBoxItem; NotificationsList.ItemsSource=notifications.Items; @@ -220,7 +220,7 @@ private async void CheckWorkspace(object? sender,EventArgs e) } private void ShowMonitorNotification(EnvironmentWorkspace state) { - var pending=state.MonitoringEnabled && state.AutoArrangeMachinePath && state.PendingPathOrganizations.Any(item=>item.Scope==Pathman.Core.Models.VariableScope.Machine); + var pending=!new GlobalMonitoring(layout).IsEnabled && state.MonitoringEnabled && state.AutoArrangeMachinePath && state.PendingPathOrganizations.Any(item=>item.Scope==Pathman.Core.Models.VariableScope.Machine); var signature=$"{pending}:{state.MonitorMessageTime:O}:{state.MonitorMessage}:{state.OriginTrackingMessage}"; if(signature==monitorSignature) return;monitorSignature=signature; if(state.OriginTrackingMessage is {} tracking) notifications.Show(new("monitor",L.Get("修改来源识别"),tracking+"\n"+Osc8Text.Link(L.Get("前往变量监控"),"apppage:monitor"),NotificationColor.Yellow)); @@ -230,6 +230,7 @@ private void ShowMonitorNotification(EnvironmentWorkspace state) } private async Task ApplyPendingMonitoringAsync(bool automatic=false) { + if(new GlobalMonitoring(layout).IsEnabled) return; if(monitorApplying) return; monitorApplying=true;monitor.SetApplying(true); try diff --git a/src/Pathman.Gui/Views/MonitorView.xaml b/src/Pathman.Gui/Views/MonitorView.xaml index ddb2de0..856548b 100644 --- a/src/Pathman.Gui/Views/MonitorView.xaml +++ b/src/Pathman.Gui/Views/MonitorView.xaml @@ -9,7 +9,10 @@ - + + + + @@ -24,8 +27,14 @@ - - + + + + + + + + @@ -36,22 +45,50 @@ - + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + +