From 6b95fcd0b261afe37c04cbf6abbdb1aabf141ca7 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Mon, 16 Feb 2026 20:12:51 +0100 Subject: [PATCH] (bugfix): unblock script and dependencies --- CHANGELOG.md | 9 + badges/coverage-branches.svg | 8 +- badges/coverage-lines.svg | 8 +- badges/coverage-methods.svg | 10 +- src/MaksIT.UScheduler/CHANGELOG.md | 23 -- .../MaksIT.UScheduler.csproj | 2 +- src/MaksIT.UScheduler/README.md | 357 ------------------ .../Services/PSScriptService.cs | 181 +++++++-- 8 files changed, 179 insertions(+), 419 deletions(-) delete mode 100644 src/MaksIT.UScheduler/CHANGELOG.md delete mode 100644 src/MaksIT.UScheduler/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a4b35..015b31e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## v1.0.2 - 2026-03-01 + +### Fixed +- **PowerShell module loading**: Scripts with module dependencies now execute correctly when running as Windows service + - Added recursive dependency scanning using PowerShell AST parser + - Automatically unblocks `Import-Module`, `using module`, and dot-sourced (`. ./file.ps1`) dependencies + - Resolves "AuthorizationManager check failed" errors for modules downloaded from the internet + - Supports `.psm1` and `.psd1` module files in script directory and subfolders + ## v1.0.1 - 2026-02-15 ### Added diff --git a/badges/coverage-branches.svg b/badges/coverage-branches.svg index f8e8de3..4a0fa25 100644 --- a/badges/coverage-branches.svg +++ b/badges/coverage-branches.svg @@ -1,5 +1,5 @@ - - Branch Coverage: 9.6% + + Branch Coverage: 7% @@ -15,7 +15,7 @@ Branch Coverage - - 9.6% + + 7% diff --git a/badges/coverage-lines.svg b/badges/coverage-lines.svg index 00b2fee..f5a3014 100644 --- a/badges/coverage-lines.svg +++ b/badges/coverage-lines.svg @@ -1,5 +1,5 @@ - - Line Coverage: 18.6% + + Line Coverage: 15.2% @@ -15,7 +15,7 @@ Line Coverage - - 18.6% + + 15.2% diff --git a/badges/coverage-methods.svg b/badges/coverage-methods.svg index 2f8480e..7e83000 100644 --- a/badges/coverage-methods.svg +++ b/badges/coverage-methods.svg @@ -1,5 +1,5 @@ - - Method Coverage: 43.2% + + Method Coverage: 38.8% @@ -9,13 +9,13 @@ - + Method Coverage - - 43.2% + + 38.8% diff --git a/src/MaksIT.UScheduler/CHANGELOG.md b/src/MaksIT.UScheduler/CHANGELOG.md deleted file mode 100644 index 93213d7..0000000 --- a/src/MaksIT.UScheduler/CHANGELOG.md +++ /dev/null @@ -1,23 +0,0 @@ -# MaksIT.UScheduler Changelog - -## v1.0.0 - 2025-12-06 - -### Major Changes -- Migrate of the Unified Scheduler Service in .NET 10 (previously .NET 8). -- New solution and project structure under `MaksIT.UScheduler`. -- Added support for scheduling and running both PowerShell scripts and console applications as Windows services. -- Strongly typed configuration via `appsettings.json` and `Configuration.cs`. -- Improved logging with configurable log directory. -- New background services: - - `PSScriptBackgroundService` for PowerShell script execution. - - `ProcessBackgroundService` for process management. -- Enhanced PowerShell script execution with signature validation and script unblocking. -- Improved process management with restart-on-failure logic. -- Updated install/uninstall scripts (`Install.cmd`, `Uninstall.cmd`) for service management. -- Added comprehensive README with usage, configuration, and scheduling examples. -- MIT License included. - -### Breaking Changes -- Old solution, project, and service files removed. -- Configuration format and service naming conventions updated. -- Scheduling logic for console applications is not yet implemented (runs every 10 seconds). diff --git a/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj b/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj index cdcfba6..a465ae0 100644 --- a/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj +++ b/src/MaksIT.UScheduler/MaksIT.UScheduler.csproj @@ -2,7 +2,7 @@ net10.0 - 1.0.1 + 1.0.2 enable enable dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66 diff --git a/src/MaksIT.UScheduler/README.md b/src/MaksIT.UScheduler/README.md deleted file mode 100644 index 335512e..0000000 --- a/src/MaksIT.UScheduler/README.md +++ /dev/null @@ -1,357 +0,0 @@ -# Unified Scheduler Service - -Is'a completelly rewritten in .NET8 version of **PowerShell Scrip Service** realized in .Net Framework 4.8 - -As previously, this project still has an aim to allow **System Administrators** and also to who **Thinks to be System Administrator** to launch **Power Shell** scripts and **Console Programs** as **Windows Service**. - -## Latest builds - -## How to Install and Uninstall Service - -### Service Install - -```powershell -sc.exe create "Unified Scheduler Service" binpath="C:\Path\To\UScheduler.exe" -``` - -with providing custom `contentRoot`: - -```powershell -sc.exe create "Unified Scheduler Service" binpath="C:\Path\To\UScheduler.exe --contentRoot C:\Other\Path" -``` - -Edit `appsettings.json`` according your needs. Differently from previuos version it doesn't scans a folders for scripts and same for programs, but you have explicitly set what should be launched. Also, when changes are made, you have to restart service. This will improve security of your environment. - -Then **start** your **Unified Scheduler Service** - -I have also prepared ***.cmd** file to simplify service system integration: - -Install.cmd - -```bat -sc.exe create "Unified Scheduler Service" binpath="%~dp0UScheduler.exe" -pause -``` - ->These ***.cmd** files have to be launched with **Admin** privileges. - -After installation you have to start your newly created windows service: Win+R -> services.msc -> Enter -> Search by DisplayName. - -### Service Uninstall - -```powershell -sc.exe "Unified Scheduler Service" -``` - -Uninstall.cmd - -```bat -sc.exe "Unified Scheduler Service" -pause -``` - -## How it works - -Here is a short explanation of two functional parts currently available. - -### Processes - -> Warning: For the moment I haven't realized any scheduling functionality for `console applications`, so be carefull, if your program is not a service kind, like `node derver`, `syncthing` ecc... it will execute it continuously every 10 senconds after completes. - -This functionality is aimed to execute `console app services` which do not provide any windows service integration, and keeps it always alive. - -### Powershell - -Executes scripts whith following command parameters every 10 seconds: - -```C# - myCommand.Parameters.Add(new CommandParameter("Automated", true)); - myCommand.Parameters.Add(new CommandParameter("CurrentDateTimeUtc", DateTime.UtcNow.ToString("o"))); -``` - -Retrieve parameters this way: - -```PowerShell -[CmdletBinding()] -param ( - [switch]$Automated, - [string]$CurrentDateTimeUtc -) - -# ====================================================================== -# CONFIGURATION BLOCK (only modify values here) -# ====================================================================== -$Config = @{ - RunMonth = @() # e.g. @("January","December") - RunWeekday = @() # e.g. @("Monday","Friday") - RunTime = @("20:28") # UTC times HH:mm - MinIntervalMinutes = 10 -} -# ====================================================================== - - -# ====================================================================== -# LOGGING -# ====================================================================== -function Write-Log { - param( - [string]$Message, - [string]$Color = 'White' - ) - - if ($Automated) { - Write-Output $Message - } - else { - Write-Host $Message -ForegroundColor $Color - } -} - - -# ====================================================================== -# TIME PARSING AND SCHEDULING HELPERS -# ====================================================================== -function Get-CurrentUtcDateTime { - param([string]$ExternalDateTime) - - if ($ExternalDateTime) { - try { - return [datetime]::Parse($ExternalDateTime).ToUniversalTime() - } - catch { - try { - return [datetime]::ParseExact($ExternalDateTime, 'dd/MM/yyyy HH:mm:ss', $null).ToUniversalTime() - } - catch { - Write-Log "Failed to parse CurrentDateTimeUtc ('$ExternalDateTime'). Using system time (UTC) instead." 'Red' - return (Get-Date).ToUniversalTime() - } - } - } - else { - return (Get-Date).ToUniversalTime() - } -} - -function Test-ScheduleMonth { - param([datetime]$DateTime, [array]$Months) - $monthName = $DateTime.ToString('MMMM') - return ($Months.Count -eq 0) -or ($Months -contains $monthName) -} - -function Test-ScheduleWeekday { - param([datetime]$DateTime, [array]$Weekdays) - $weekdayName = $DateTime.DayOfWeek.ToString() - return ($Weekdays.Count -eq 0) -or ($Weekdays -contains $weekdayName) -} - -function Test-ScheduleTime { - param([datetime]$DateTime, [array]$Times) - $timeString = $DateTime.ToString('HH:mm') - return ($Times.Count -eq 0) -or ($Times -contains $timeString) -} - -function Test-Schedule { - param( - [datetime]$DateTime, - [array]$Months, - [array]$Weekdays, - [array]$Times - ) - return (Test-ScheduleMonth -DateTime $DateTime -Months $Months) -and - (Test-ScheduleWeekday -DateTime $DateTime -Weekdays $Weekdays) -and - (Test-ScheduleTime -DateTime $DateTime -Times $Times) -} - -function Test-Interval { - param([datetime]$LastRun, [datetime]$Now, [int]$MinIntervalMinutes) - return $Now -ge $LastRun.AddMinutes($MinIntervalMinutes) -} - - -# ====================================================================== -# MAIN SCHEDULING CHECK -# ====================================================================== -function Test-ShouldExecute { - param( - [switch]$Automated, - [string]$CurrentDateTimeUtc, - [hashtable]$Config, - [string]$LastRunFilePath - ) - - $result = @{ - ShouldExecute = $true - Now = $null - } - - $now = Get-CurrentUtcDateTime -ExternalDateTime $CurrentDateTimeUtc - $result.Now = $now - - if ($Automated) { - Write-Log "Automated: $Automated" 'Green' - Write-Log "Current UTC Time used: $now" 'Green' - - if (-not (Test-Schedule -DateTime $now -Months $Config.RunMonth -Weekdays $Config.RunWeekday -Times $Config.RunTime)) { - Write-Log "Execution skipped due to schedule (Month: $($Config.RunMonth), Weekday: $($Config.RunWeekday), Time: $($Config.RunTime))" 'Yellow' - $result.ShouldExecute = $false - return $result - } - } - - if ($LastRunFilePath -and (Test-Path $LastRunFilePath)) { - $lastRun = Get-Content $LastRunFilePath | Select-Object -First 1 - if ($lastRun) { - [datetime]$lastRunDT = [datetime]::Parse($lastRun) - if (-not (Test-Interval -LastRun $lastRunDT -Now $now -MinIntervalMinutes $Config.MinIntervalMinutes)) { - Write-Log "Last run at $lastRunDT. Interval not reached." 'Yellow' - $result.ShouldExecute = $false - } - } - } - - return $result -} - - -# ====================================================================== -# LOCK FILE HANDLING -# ====================================================================== -function New-LockGuard { - param([string]$LockFile) - - if (Test-Path $LockFile) { - Write-Log "Guard: Existing lock file ($LockFile). Skipping execution." 'Red' - return $false - } - - try { - New-Item -Path $LockFile -ItemType File -Force | Out-Null - return $true - } - catch { - Write-Log "Guard: Failed to create lock file ($LockFile). Skipping." 'Red' - return $false - } -} - -function Remove-LockGuard { - param([string]$LockFile) - if (Test-Path $LockFile) { - Remove-Item $LockFile -Force - Write-Log "Lock file removed: $LockFile" 'Cyan' - } -} - - -# ====================================================================== -# BUSINESS LOGIC PLACEHOLDER -# ====================================================================== -function Run-BusinessLogic { - # Put your actual logic here - Write-Log "Executing business logic..." 'Green' - - # ... - # ... -} - - -# ====================================================================== -# MAIN EXECUTION FLOW -# ====================================================================== -$scriptPath = $MyInvocation.MyCommand.Path -$lastRunFile = [System.IO.Path]::ChangeExtension($scriptPath, ".lastRun") -$lockFile = [System.IO.Path]::ChangeExtension($scriptPath, ".lock") - -$schedule = Test-ShouldExecute -Automated:$Automated -CurrentDateTimeUtc $CurrentDateTimeUtc -Config $Config -LastRunFilePath $lastRunFile - -if (-not $schedule.ShouldExecute) { - Write-Log "Execution skipped." 'Yellow' - return -} - -if (-not (New-LockGuard -LockFile $lockFile)) { - return -} - -try { - $schedule.Now.ToString("o") | Set-Content $lastRunFile - Run-BusinessLogic -} -finally { - Remove-LockGuard -LockFile $lockFile -} -``` - -Thanks to that, it's possible to create standalone scripts or automated scheduled scripts, which will be executed according to the script managed schedule logic. - -For every new scheduled script: -* Copy the template. -* Modify only the Config block and Run-BusinessLogic. -* Leave everything else untouched. - -Done — all scheduling, locking, and logging works automatically. - -### Thread organization - -Every script and program is launched in its **own thread**, so if one crashes, others are able to continue: - -``` - Unified Scheduler Service Thread - ├── Powershell - │ ├── /Scripts/SomeStuff_1/StartScript.ps1 Thread - │ ├── /Scripts/SomeStuff_2/StartScript.ps1 Thread - │ └── ... - └── Processes - ├── /Programs/SomeStuff_1/Program.exe - ├── /Programs/SomeStuff_2/Program.exe - └── ... -``` - -> By default It's set to execute only **signed** scrips, but if you don't care about your environment security, it's possible to launch them in **unrestricted** mode. -> -> Continue to read to see other possible settings... - -## Configurations - -Here are all currently available configurations inside `appsettings.json`: - -```json -{ - //... - - "Configurations": { - "ServiceName": "UScheduler", - "Description": "Windows service, which allows you to invoke PowerShell Scripts and Processes", - "DisplayName": "Unified Scheduler Service", - - "Powershell": [ - { - "Path": "C:\\UScheduler\\Scripts\\Demo\\StartScript.ps1", - "Signed": true - } - ], - - "Processes": [ - { - "Path": "C:\\UScheduler\\Programs\\syncthing-windows-amd64-v1.27.1\\syncthing.exe", - "Args": [], - "RestartOnFailure": true - } - ] - } -} -``` - -Let's see each one: - -* ServiceName - System service name. I suggest to use short names without spaces or other strange characters. See [What are valid characters in a Windows service (key) name?](https://stackoverflow.com/questions/801280/what-are-valid-characters-in-a-windows-service-key-name). -* Description - Description you wants to give to this service. Just put something very serious and technically complex to admire what kind of DUDE you are! -* DisplayName - Same thing like for ServiceName, but you are free to use spaces. -* Powershell: - * ScriptsPath - Specify script to launch. - * SignedScripts - **true** for **AllSigned** or **false** for **Unrestricted**. -* Processes: - * Path - Specify program to launch. - * Args - Program command line arguments - * RestartOnFailure - Allows to restart if something went wrong with program. \ No newline at end of file diff --git a/src/MaksIT.UScheduler/Services/PSScriptService.cs b/src/MaksIT.UScheduler/Services/PSScriptService.cs index 6be9272..dfd96db 100644 --- a/src/MaksIT.UScheduler/Services/PSScriptService.cs +++ b/src/MaksIT.UScheduler/Services/PSScriptService.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Management.Automation; +using System.Management.Automation.Language; using System.Management.Automation.Runspaces; using MaksIT.UScheduler.Shared.Helpers; using MaksIT.UScheduler.Shared.Extensions; @@ -50,7 +51,7 @@ public sealed class PSScriptService : IPSScriptService { public async Task RunScriptAsync(string scriptPath, bool signed, CancellationToken stoppingToken) { // Resolve relative paths against application base directory var resolvedPath = PathHelper.ResolvePath(scriptPath); - + _logger.LogInformation($"Preparing to run script {resolvedPath}"); if (GetRunningScriptTasks().Contains(resolvedPath)) { @@ -63,8 +64,8 @@ public sealed class PSScriptService : IPSScriptService { return; } - if (!TryUnblockScript(resolvedPath)) { - _logger.LogError($"Script {resolvedPath} could not be unblocked. Aborting execution."); + if (!EnsureDependenciesUnblocked(resolvedPath)) { + _logger.LogError($"Script or dependencies for {resolvedPath} could not be unblocked. Aborting execution."); return; } @@ -140,27 +141,6 @@ public sealed class PSScriptService : IPSScriptService { } } - /// - /// Attempts to unblock a downloaded script by removing the Zone.Identifier alternate data stream. - /// This is equivalent to right-clicking a file and selecting "Unblock" in Windows. - /// - /// The path to the script to unblock. - /// True if the script was successfully unblocked or was not blocked; false if unblocking failed. - private bool TryUnblockScript(string scriptPath) { - try { - var zoneIdentifier = scriptPath + ":Zone.Identifier"; - if (File.Exists(zoneIdentifier)) { - File.Delete(zoneIdentifier); - _logger.LogInformation($"Unblocked script {scriptPath} by removing Zone.Identifier."); - } - return true; - } - catch (Exception ex) { - _logger.LogWarning($"Failed to unblock script {scriptPath}: {ex.Message}"); - return false; - } - } - /// /// Gets a list of script paths that are currently being executed. /// @@ -214,4 +194,155 @@ public sealed class PSScriptService : IPSScriptService { _runspacePool?.Dispose(); _logger.LogInformation("RunspacePool disposed"); } -} + + /// + /// Recursively scans a PowerShell script for module and dot-sourced dependencies and unblocks them. + /// + /// The entry script path. + /// True if all scripts and dependencies were unblocked; false otherwise. + private bool EnsureDependenciesUnblocked(string scriptPath) { + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + + var queue = new Queue(); + queue.Enqueue(scriptPath); + + bool allUnblocked = true; + + while (queue.Count > 0) { + var current = queue.Dequeue(); + + if (!visited.Add(current)) + continue; + + if (!TryUnblockScript(current)) { + _logger.LogError($"Failed to unblock dependency: {current}"); + allUnblocked = false; + } + + // Scan for dependencies + try { + if (!File.Exists(current)) + continue; + + var currentDir = Path.GetDirectoryName(current); + + var ast = Parser.ParseFile(current, out var tokens, out var errors); + + // Handle 'using module' statements (UsingStatementAst) + foreach (var usingAst in ast.FindAll(a => a is UsingStatementAst, true)) { + var usingStmt = (UsingStatementAst)usingAst; + if (usingStmt.UsingStatementKind == UsingStatementKind.Module && usingStmt.Name != null) { + var depName = usingStmt.Name.Value; + + var depPath = ResolveModulePath(depName, currentDir); + if (!string.IsNullOrEmpty(depPath)) + queue.Enqueue(depPath); + } + } + + // Handle Import-Module commands + foreach (var cmdAst in ast.FindAll(a => a is CommandAst, true)) { + var cmd = (CommandAst)cmdAst; + + var name = cmd.GetCommandName(); + if (string.Equals(name, "Import-Module", StringComparison.OrdinalIgnoreCase)) { + foreach (var arg in cmd.CommandElements.Skip(1)) { + var depName = arg.ToString().Trim('"', '\'', ' '); + + // Skip parameters like -Name, -Force, etc. + if (depName.StartsWith("-", StringComparison.Ordinal)) + continue; + + var depPath = ResolveModulePath(depName, currentDir); + if (!string.IsNullOrEmpty(depPath)) + queue.Enqueue(depPath); + } + } + } + + // Handle dot-sourcing: . ./file.ps1 + foreach (var cmdAst in ast.FindAll(a => a is CommandAst, true)) { + var cmd = (CommandAst)cmdAst; + if (cmd.InvocationOperator == TokenKind.Dot) { + var arg = cmd.CommandElements.FirstOrDefault(); + if (arg != null && !string.IsNullOrEmpty(currentDir)) { + var depName = arg.ToString().Trim('"', '\'', ' '); + var depPath = Path.Combine(currentDir, depName); + if (File.Exists(depPath)) + queue.Enqueue(depPath); + } + } + } + } + catch (Exception ex) { + _logger.LogWarning($"Dependency scan failed for {current}: {ex.Message}"); + } + } + + return allUnblocked; + } + + /// + /// Attempts to resolve a module path from a module name or path. + /// + /// Module name or path. + /// Base directory for relative paths. + /// Resolved module file path or null. + private string? ResolveModulePath(string moduleName, string? baseDir) { + // If it's a path, resolve relative to baseDir + if (moduleName.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase) || moduleName.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase)) { + if (Path.IsPathRooted(moduleName)) { + if (File.Exists(moduleName)) + return moduleName; + } + else if (!string.IsNullOrEmpty(baseDir)) { + var path = Path.Combine(baseDir, moduleName); + if (File.Exists(path)) + return path; + } + } + + // Try to find module in same directory + if (!string.IsNullOrEmpty(baseDir)) { + var psm1 = Path.Combine(baseDir, moduleName + ".psm1"); + if (File.Exists(psm1)) + return psm1; + + var psd1 = Path.Combine(baseDir, moduleName + ".psd1"); + if (File.Exists(psd1)) + return psd1; + + // Try subfolder with module name (common module structure) + var subPsm1 = Path.Combine(baseDir, moduleName, moduleName + ".psm1"); + if (File.Exists(subPsm1)) + return subPsm1; + + var subPsd1 = Path.Combine(baseDir, moduleName, moduleName + ".psd1"); + if (File.Exists(subPsd1)) + return subPsd1; + } + + return null; + } + + /// + /// Attempts to unblock a downloaded script by removing the Zone.Identifier alternate data stream. + /// This is equivalent to right-clicking a file and selecting "Unblock" in Windows. + /// + /// The path to the script to unblock. + /// True if the script was successfully unblocked or was not blocked; false if unblocking failed. + private bool TryUnblockScript(string scriptPath) { + try { + var zoneIdentifier = scriptPath + ":Zone.Identifier"; + if (File.Exists(zoneIdentifier)) { + File.Delete(zoneIdentifier); + _logger.LogInformation($"Unblocked script {scriptPath} by removing Zone.Identifier."); + } + return true; + } + catch (Exception ex) { + _logger.LogWarning($"Failed to unblock script {scriptPath}: {ex.Message}"); + return false; + } + } +} \ No newline at end of file