github aelassas/servy v10.2
Servy 10.2

3 hours ago

Servy v10.2 introduces full per-service security isolation (see Security.md and Architecture.md for details), a new CLI show command, automated ACL hardening, and core engine stability improvements.

Key Highlights

  • Full Per-Service Security Isolation: Custom service accounts can no longer read or modify other services' configurations, runtime states, or logs. Decryption and database access are now proxied through the isolated Servy host process (Servy.Host.exe) via a DACL-restricted local Named Pipe.
  • Per-Service Log Isolation: Each wrapped service now writes its wrapper and restarter logs to an isolated subfolder (%ProgramData%\Servy\logs\service\<ServiceName>\). Custom service accounts hold permissions strictly within their own log subfolder and cannot access or tamper with logs from other services.
  • Automated Vault & Binary Hardening: Servy automatically enforces strict ACL lockdown rules on the %ProgramData%\Servy root vault and core binaries (.exe/.dll), removing the need for manual sysadmin hardening scripts or elevated permissions setup.
  • CLI show Command (#7018): Inspect and print service configurations directly from the terminal in a clean, human-readable format.

Complete List of Changes

  • Security & IPC Proxy: Introduced Servy.Host.exe for local IPC configuration and state requests, enforced process identity verification (GetNamedPipeClientProcessId vs SCM PID), hardened vault permissions, added per-service log folder isolation, improved NTFS hard-link safeguards, and strengthened key-vault access rules.
  • CLI & PowerShell: Added the show command (#7018) and fixed wildcard path resolution issues in Servy.psm1.
  • Core Engine & Process Management: Resolved concurrency race conditions in log stream rotation and handle drain timeouts, corrected environment variable line parsing, and cleaned up unused constants.
  • Desktop App & Servy Manager: Preserved node expansion states in the Dependencies tab, fixed log-tailing scroll jumps, resolved secondary-tab automation name collisions, and localized event log level displays.
  • Notifications & Task Scheduler: Prevented stale-read race conditions during watermark updates, improved WinRT type load error classification, and fixed bracket truncation in service message parsing.
  • CI/CD & Release Tooling: Streamlined test coverage gates, fixed SonarCloud package caching paths, filtered stress tests out of release builds, and hardened Markdown tag escaping in changelog scripts.

Breaking Changes

  1. Reserved Service Name (Servy): The service name "Servy" is now reserved for the Servy Host service (Servy.Host.exe) that manages secure IPC communication. If you previously installed a Windows service named "Servy", you must rename it prior to installing v10.2.
  2. Fixed Vault File Locations: Custom directory paths for Servy.db and keying material are no longer supported. The configuration database and security keys must reside in %ProgramData%\Servy\db and %ProgramData%\Servy\security, where they are automatically hardened and managed by the host process.

Full Changelog

Click to expand release notes!
  • feat(cli,psm1): Add show command to display service configuration in a human-readable format (#7018)
  • feat(security): Add per-service security isolation through secure IPC, DACLs and ACLs
  • feat(security): harden the vault in C# instead of Set-ServyExePermissions.ps1 and re-apply the hardening whenever a binary is extracted
  • fix(security): Set-ServyExePermissions.ps1 - the handle64/handle64a probe is if/elseif, so a machine with both binaries present (after an architecture migration) leaves one unhardened and unreported (#6855)
  • fix(security): Set-ServyExePermissions.ps1 - Get-Acl/Set-Acl follow NTFS hard links with no reparse/link-count check, letting the runner account redirect the admin's own hardening run onto an arbitrary file (#6866)
  • fix(security): Set-ServyExePermissions.ps1 - the admin-membership warning condition is written twice, and the final-summary copy has already drifted (lost a space around -eq) (#6870)
  • fix(security): Set-ServyExePermissions.ps1 - Test-ServyAdminGroupMember's S4U fast path takes a UPN, so none of the five documented -TargetAccount formats can ever reach it and the ADSI fallback always runs (#7008)
  • fix(security): Set-ServyExePermissions.ps1 - the #6866 hard-link check fails open: a non-zero fsutil exit or empty output is treated as 'one link' and the file is hardened anyway (#7009)
  • fix(security): Set-ServyExePermissions.ps1 - grant Modify on %ProgramData%\Servy itself and drop Delete on db\Servy.db, instead of telling users to grant Modify by hand (#7136)
  • fix(security): Set-ServyExePermissions.ps1 - the vault Modify grant gives the runner Delete on db\ and on the vault folder itself, so it can rename db\ away and plant its own Servy.db, bypassing the #7136 no-Delete ACE on the file (#7140)
  • fix(security): Set-ServyExePermissions.ps1 - the #7140 Deny Delete ACE on every vault folder is absent from the header, and the root audit line prints only [Modify - Allow] (#7143)
  • fix(dump,restore): Servy-Restore.ps1 / Servy-Dump.ps1 - -WhatIf is honoured only by the SCM branch, so a what-if restore still overwrites the database and both scripts leave their plain-text staging directory behind (#7014)
  • fix(core): grant service control and status rights on SCM handle during install and update
  • fix(core): AppConfig.cs - four public constants have zero consumers; ConsoleSpinnerDelayMs' own call site still hardcodes the literal (residual of #1186) (#5499)
  • fix(core): ProcessKiller.cs - two provably-unreachable defensive terms: parentStartTime's MinValue initializer (residue of #2214/#1231) and a ?? on ProcessInfoNode.Name (#5515)
  • fix(core): ServiceDtoImportValidator.cs - TryValidate's 'errorMessage set on failure' invariant is prose-only; the #4397 fix's NotNullWhen never reached the repo's other Try-pattern (#5986)
  • fix(core): ServiceValidationRules.cs - Validate normalizes three fields and discards the result, so the CLI and Manager import paths persist the un-normalized string that was never validated (#5987)
  • fix(core): ServiceManager.cs - four 'repository is not initialized' guards re-check a readonly field the constructor already pinned non-null (#6375)
  • fix(core): Domain/Service.cs - GetServiceStartupType has no caller in src, returns ServiceStartType? and documents a null for a missing service that IServiceManager's non-nullable Unknown contract never produces (#6674)
  • fix(core): ServiceHelper.cs - StopServicesAsync has no fast-fail for a service that re-enters Running during the stop wait, unlike StartServicesAsync's Stopped fast-fail (#6806)
  • fix(core): ProcessKiller.cs - the #6782 provenance check silently reverts to name-only trust when ExecutablePath is null, exactly the failure mode SystemProcessWrapper already documents (#6816)
  • fix(core): HandleHelper.cs - the timeout path's unsynchronized errorBuilder read races the still-running async handler when Kill() fails to terminate within the drain window (#6842)
  • fix(core): HandleHelper.cs - the lock-protected timeout-path errorBuilder read is atomic but can still be incomplete when Kill() does not confirm termination within the drain window (#6844)
  • fix(core): RotatingStreamWriter.cs - the rotation wait-timeout recovery lets a writer reattach to a file PerformPhysicalRotation may still be moving, silently misdirecting writes into the archive (#6873)
  • fix(core): Logger.cs - WriteLeveled discards a provided exception when the message argument is null or empty (#6887)
  • fix(core): ServiceControllerProvider.cs - GetService throws ArgumentNullException for a blank-but-non-null serviceName, unlike every sibling IsNullOrWhiteSpace guard (#6890)
  • fix(core): AppFoldersHelper.cs - rootVaultPath is the only EnsureFolders path parameter without an absolute-path guard, unlike its three siblings (#6897)
  • fix(core): AppConfig.cs - the portable deployment's Windows Service ImagePath resolves from an unhardened, arbitrary extraction folder (#6923)
  • fix(core): Helper.cs - WriteFileAtomicCore has no fallback for a hardened-ACL UnauthorizedAccessException, unlike its sync sibling WriteFileAtomic (#6928)
  • fix(core): Helper.cs - the transient-retry catch's UnauthorizedAccessException arm is unreachable behind the preceding catch clause, in both WriteFileAtomic and WriteFileAtomicCore (#6932)
  • fix(core): ServiceHelper.cs - StopServicesAsync's wait loop refreshes AFTER the delay, reintroducing the exact stale-read race #181 already fixed on the Start side (#6943)
  • fix(core): ServiceManager.cs - two of GetAllServices' three 'details unavailable' sentinels are hardcoded English while the #5981 one reads a Strings resource (#6957)
  • fix(core): ServiceManager.cs - ToScmStartType returns the managed enum's numeric value where UninstallServiceAsync names SERVICE_DEMAND_START for the same native parameter (#6959)
  • fix(core): EnvironmentVariableParser.cs - the #6111 fix appends ' (record N).' to two resource messages that already end in a period, so both exceptions now read '... empty. (record 1).' (#6993)
  • fix(core): EnvironmentVariablesValidator.cs / Strings.resx (Core) - the #6993 fix moved '(record {0})' into two resource values the validator still assigns unformatted, so Validate now reports '... (record {0}).' verbatim (#6997)
  • fix(core): EnvironmentVariableParser.cs - the '(record N)' position is the raw split index, so CRLF-separated input reports the second line as record 3 and every later line as 2n-1 (#6999)
  • fix(core): AppFoldersHelper.cs - the relative Data Source guard is the one check in its block that throws ArgumentException, and its ParamName names the local dbFolder (#7017)
  • fix(core): ProtectedKeyProvider.cs - the #5953 fix deleted mutexSecurity.AddAccessRule, so the key-vault mutex is created with an empty MutexSecurity and the AuthenticatedUsers rule is built and never applied (#7061)
  • fix(core): Helper.cs - IsVolumeMountPoint trusts DirectoryInfo.LinkTarget, which returns the junction's creator-chosen print name, so since #7100 any junction printed as 'Volume{...}' bypasses HasAncestorReparsePoint and the service's log-path guard (#7141)
  • fix(core): Helper.cs - WriteFileAtomic's hardened-target fallback deletes the destination before the move, so a move that keeps failing or a cancellation during the back-off loses both the old file and the new one (#7154)
  • fix(core): Helper.cs - after #7154 the hardened-target fallback moves the target aside, but its AggregateException and retry log still say "explicit delete fallback" (net48 already says "fallback move") (#7166)
  • fix(infra): DapperExecutor.cs - the retry-exhaustion exception names AppConfig.Db*MaxRetries, which does not exist, and the sync log still says 'Spinning' over a Thread.Sleep (residual of #1381) (#5989)
  • fix(infra): ServiceRepository.cs - the decryption-marker strip regex hand-copies two interpolated literals from the same file, so any reword silently persists the UI marker into Description (#5992)
  • fix(infra): SqlConstants.cs - UpsertSet hardcodes 'Services.' nine lines below the ServicesTableName constant that owns the name (residual of #3278) (#6394)
  • fix(service): ServiceHelper.cs - EnvironmentVariablesToString prints "None" for a null list that StartOptions never produces and a blank for the empty list every service without custom variables has (#6515)
  • fix(service): ServiceHelper.cs - LogStartupArguments null-guards an options argument that ValidateAndLog, its only caller, dereferences on the next statement; OnStart already throws on null before either (#6516)
  • fix(service): StartOptionsParser.cs - SafeParseEnvVars's ArgumentException catch arm is unreachable; EnvironmentVariableParser.Parse only ever throws FormatException (#6902)
  • fix(restarter): ServiceController.cs / IServiceController.cs (Restarter) - re-declare an abstraction Servy.Core already provides as IServiceControllerWrapper, and the copy shadows the BCL type name it wraps (#5620)
  • fix(restarter): ServiceRestarter.cs - the Stop/Start pre-wait timeout exceptions say 'while waiting' although WaitForStatus was never called this pass, contradicting the log line one line above (#6901)
  • fix(ui): AppBootstrapper.cs - EnsureEventSourceExists and CoreSettingsLoader.Validate still run before the stopwatch starts, so the splash floor is still measured from the wrong origin (residual of #6205) (#6882)
  • fix(ui): AppBootstrapper.cs - the #6559 fix restored the message-keyed debounce #6206 removed, so the 'key on the fault SITE' comment now sits above a key that includes the message (#7032)
  • fix(desktop): MainViewModel.cs (Servy) - BindServiceDtoToModel is the one DTO consumer that never calls HydrateDefaults, so it keeps a third copy of the default table (30 sites, 11 as four-clause ternaries) that every helper extension leaves behind (#6592)
  • fix(desktop): trings.resx / MainWindow.xaml (Servy) - 17 automation names are verbatim copies of the label they sit next to and one hook hint is stored 5 times; #5260 fixed the drift but not the copies that caused it (#5664)
  • fix(desktop): ServiceCommands.cs (Servy) - OpenManagerAsync and OpenSecurityHardeningGuideAsync check the token before the try, so their OperationCanceledException re-throw arms guard nothing in production; the four Export/Import siblings check inside it (#6647)
  • fix(desktop,manager): ServiceCommands.cs (Servy + Manager) - the injected XML/JSON serializers are stored but never read since #5964, so the Manager suite's five 'single-parse invariant' Deserialize asserts cannot fail (#7126)
  • fix(manager): App.xaml (Manager) - the four-brush selection remap is copy-pasted between the DataGrid and TreeViewItem styles, keeping #0078D7 and White as four literals each after #4833 centralized them into this file (#5582)
  • fix(manager): ConsoleViewModel.cs - the #4348 type guard is unreachable; MemberwiseClone cannot return a type other than the one it was called on (#5595)
  • fix(manager): PidBadgeControl.xaml / PerformanceView.xaml / App.xaml - the #1B7F9F accent is a literal at five sites in three files, and #757575 at two, after #4833 and #5222 centralized the selection and row-hover colours (#5611)
  • fix(manager): ConsoleViewModel.cs - both StartLiveTail guards re-test a condition their result object already encodes, and the stderr comment credits the dead term with preventing duplicate entries (#6005)
  • fix(manager): LogsView.xaml / LogsViewModel.cs (Manager) - EventLogLevel is the only user-facing enum in either app with no localized display, so the log-level filter renders raw English enum names (#6072)
  • fix(manager): Strings.resx (Manager) - Automation_LogsTab_HelpText scopes the Logs tab to 'the service' although the Logs view filters all services (#6461)
  • fix(manager): Strings.resx (Manager) - three sibling string pairs drift by one word each (Console tab/root, the two already-exists confirmations, the two desktop-app errors) (#6462)
  • fix(manager): MainViewModel.cs (Manager) - both callers re-catch what RefreshAllServicesAsync already swallows: the step-6 catch blocks are unreachable and 'RefreshAllServicesAsync failed.' can never be logged (#6479)
  • fix(manager): DependenciesView.xaml - the cyclic (#F39C12) and unavailable (#E67E22) colours are each written as a literal in three separate styles that must agree (sibling of #5611) (#6488)
  • fix(manager): DependenciesView.xaml / ConsoleView.xaml / PerformanceView.xaml - seven single-child StackPanel wrappers left behind by the #4832 PID-badge consolidation (#6499)
  • fix(manager): BoolToVisibilityConverter.cs - re-implements the framework's BooleanToVisibilityConverter; the three consumers bind a plain bool, so the only difference (Binding.DoNothing fallback) is unreachable (#6563)
  • fix(manager): HistoryResult.cs - constructor's must-be-UTC guard rejects Local but silently accepts Unspecified, which both existing tests happen to pass (#6823)
  • fix(manager): ConsoleView.xaml.cs - deselecting a log line resumes tailing and jumps to bottom even when scrolled away, bypassing the documented at-bottom check (#6881)
  • fix(manager): DependenciesView.xaml - the toolbar row's Grid declares six ColumnDefinitions but only five children ever use Grid.Column, so the trailing Auto column is dead markup (#6900)
  • fix(manager): Strings.resx / MainWindow.xaml / ServiceListControl.xaml - the sidebar search box and grid reuse MainWindow's own automation Names, so two elements answer to 'Search Services' once any secondary tab is opened (#6921)
  • fix(manager): DependenciesViewModel.cs - the #6006 expansion capture also runs on selection change, so branches the user opened on one service re-open by name on the next service's tree (#7006)
  • fix(cli): InstallServiceOptions.cs - the --user help text still hardcodes the SERVY_PASSWORD literal while --password 20 lines below composes it from AppConfig.PasswordEnvVarName (residual of #1432) (#5888)
  • fix(cli): ImportServiceCommand.cs - the Msg_ImportInstallGeneralFailure fallback at line 282 is unreachable, OperationResult.Failure rejects a blank message so a failed result always carries ErrorMessage (#6640)
  • fix(cli): InstallServiceOptions.cs - six of seven EnableHealthMonitoring-gated options still omit the Only used when health monitoring is enabled caveat from their --help text (#6744)
  • fix(cli): InstallServiceOptions.cs - RecoveryAction is the seventh EnableHealthMonitoring-gated option, and the #6744 fix that added the caveat to its six siblings left it out (#6829)
  • fix(cli): ImportServiceCommand.cs - the injected XML/JSON serializers are stored but never read, the CLI twin that the #7126 fix left behind (#7127)
  • fix(psm1): Servy.psm1 - the 28,000-character sensitive-value length cap is a magic number repeated at 8 sites (#6712)
  • fix(psm1): Servy.psm1 - the 19 remaining Test-Path calls bind the caller's path to -Path, so a bracketed executable, directory or log path fails validation as not found (residual of #6686) (#7015)
  • fix(psm1): Servy.psm1 - Set-ServyHardenedFileAcl's PS 2.0 directory branch builds and serializes an empty DirectorySecurity, then overwrites it with GetAccessControl (#7145)
  • fix(notifications): Servy-Watermark.psm1 - a service name containing ']' is truncated by the lazy bracket match in ConvertFrom-ServyEventMessage (#6305)
  • fix(notifications): ServyFailureEmail.vbs / ServyFailureNotification.vbs - the twins differ only in a hardcoded sibling filename, so #4782's convergence is maintained by hand and no test covers either file (#6683)
  • fix(notifications): Servy-Watermark.psm1 - Update-Watermark's compare-then-commit still races across concurrent instances after the #1676/#4207 fixes; the unique temp file stops staging collisions but not a stale-read commit clobbering a newer one (#6750)
  • fix(notifications): Write-ServyLog.ps1 - rotated-filename stamp format and 1MB default rotation size still diverge from RotatingStreamWriter.cs / AppConfig.cs (residual of #5880) (#6858)
  • fix(notifications): Get-ServyLastErrors.ps1 - the LastProcessed validation catch can never fire, so a malformed watermark silently becomes a bounded first-run fetch instead of an error (#6922)
  • fix(notifications): ServyFailureNotification.ps1 - a failed WinRT type load is classified TransientFailure, so a host without the toast stack never advances the watermark and logs one fallback line per Servy error forever (mirror of #5878) (#7012)
  • fix(notifications): Write-ServyLog.ps1 - Test-Path, Get-Item and Get-ChildItem take the caller's log path through -Path, so a bracketed log directory silently skips rotation and pruning (the #4997/#7011 class) (#7013)
  • fix(notifications): Servy-Watermark.psm1 - #7053 made Read-Watermark's guard literal but not its Get-Content, so a bracketed script directory is never read back and every run drops all but the newest event; ServyFailureEmail.ps1's config and credential reads are still on -Path (#7054)
  • ci(test.yml): 'dotnet new tool-manifest' is the one unguarded call of the coverage-merge step's three; its failure is reported as 'dotnet tool install failed' (residual of #3153) (#5833)
  • ci(test.yml): the coverage-upload gate expression is repeated verbatim in five step conditions, so a rule change is a five-line edit and a drifted copy skips or runs one upload silently (#6982)
  • ci(test-project.yml): project_name accepts 'filename or path', but the Get-ChildItem -Filter lookup cannot match a path and silently takes the first of several partial-name matches (#6961)
  • ci(test-project.yml): the project lookup stores its result in $matches, the automatic variable the next -match operator silently replaces (#6981)
  • ci(publish.yml): the x64 'Run all tests' step is the only CI dotnet test call without --filter Category!=Stress, so the stress suites #6123 gated out of test.yml still run on every publish (#6962)
  • ci(choco.yml): 'choco push' is the release chain's last unretried network call; both downloads and the git push honour MAX_RETRIES, the publish itself is single-shot (#5834)
  • ci(choco.yml): the installer URL/filename template staged into GITHUB_ENV is rebuilt by hand in the SHA256 and chocolateyinstall steps (#6273)
  • ci(security.yml): CodeQL init lists both security-extended and its superset security-and-quality; the first suite is fully contained in the second (#6278)
  • ci(security.yml): gitleaks-action gets GITHUB_TOKEN specifically to comment on PRs with findings, but the job has no pull-requests: write to make that call succeed (#6835)
  • ci(security.yml): MAX_RETRIES is declared in both jobs but only reaches setup-dotnet, not the dotnet restore/list/build calls (#6947)
  • ci(loc.yml): the badge deploy force-pushes the loc branch with no concurrency group, so two pushes to main can leave the older commit's counts published (residual of #4177) (#5831)
  • ci(loc.yml): the badge-deploy push still inlines secrets.GITHUB_TOKEN and github.repository in the run script, missed by the #4963 'last two' inventory (#6274)
  • ci(sonar.yml): the 'Cache SonarCloud packages' step caches ~\sonar\cache, a path the scanner never writes, so no cache is ever saved and the JRE is re-downloaded on every run (#6960)
  • ci(wiki.yml): the dotnet test/vstest filter for ProtectedVariablesDocumentationTests exits 0 when zero tests match, so a renamed or deleted test class reports the parity check green (#6865)
  • ci(changelog.yml): six of the seven explicit code points in dashPattern are already inside the \p{Pd} class the same regex ends with (#6271)
  • ci(changelog.yml): the generator still has no tag-escaping, so #6815's fix (a fourth CHANGELOG.md hand-patch, not the generator) does not stop the class recurring (#6834)
  • ci(changelog.yml): Protect-MarkdownTags misparses 3+-backtick fences, leaving text after a fence unescaped (the #6834 fix's own gap) (#6837)
  • ci(changelog.yml): Protect-MarkdownTags's early-return guard is 'return$Text' (missing space), so an empty/null $Text crashes the step instead of returning (#6838)
  • ci(changelog.yml): Protect-MarkdownTags only escapes attribute-free tags, so an attributed raw XML tag still gets silently stripped by GitHub (#6839)
  • ci(changelog.yml): MAX_RETRIES is declared and used for the push but the release-fetch gh api call has no retry at all (#6946)
  • ci(changelog.yml): unguarded IndexOf(sectionEndTag, secStartIdx) throws ArgumentOutOfRangeException when the Servy.sln ProjectSection(SolutionItems) block is missing (#6949)
  • chore: various robustness, security, inconsistency and code quality fixes
  • chore(deps): update dependencies

Don't miss a new servy release

NewReleases is sending notifications on new releases.