From 863d1c752a0eecb84f2abca4a243348bef3bb224 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Tue, 19 Mar 2024 16:39:23 -0500 Subject: [PATCH 01/19] poc: atomic runner service --- Public/AtomicRunnerService.ps1 | 1120 +++++++++++++++++++++++++++ Public/Invoke-SetupAtomicRunner.ps1 | 64 +- 2 files changed, 1126 insertions(+), 58 deletions(-) create mode 100644 Public/AtomicRunnerService.ps1 diff --git a/Public/AtomicRunnerService.ps1 b/Public/AtomicRunnerService.ps1 new file mode 100644 index 0000000..f5fcbc3 --- /dev/null +++ b/Public/AtomicRunnerService.ps1 @@ -0,0 +1,1120 @@ +############################################################################### +# # +# File name AtomicRunnerService.ps1 # +# # +# Description A sample service in a standalone PowerShell script # +# # +# Notes The latest AtomicRunnerService.ps1 version is available in GitHub # +# repository https://github.com/JFLarvoire/SysToolsLib/ , # +# in the PowerShell subdirectory. # +# Please report any problem in the Issues tab in that # +# GitHub repository in # +# https://github.com/JFLarvoire/SysToolsLib/issues # +# If you do submit a pull request, please add a comment at # +# the end of this header with the date, your initials, and # +# a description of the changes. Also update $scriptVersion. # +# # +# The initial version of this script was described in an # +# article published in the May 2016 issue of MSDN Magazine. # +# https://msdn.microsoft.com/en-us/magazine/mt703436.aspx # +# This updated version has one major change: # +# The -Service handler in the end has been rewritten to be # +# event-driven, with a second thread waiting for control # +# messages coming in via a named pipe. # +# This allows fixing a bug of the original version, that # +# did not stop properly, and left a zombie process behind. # +# The drawback is that the new code is significantly longer,# +# due to the added PowerShell thread management routines. # +# On the other hand, these thread management routines are # +# reusable, and will allow building much more powerful # +# services. # +# # +# Dynamically generates a small AtomicRunnerService.exe wrapper # +# application, that in turn invokes this PowerShell script. # +# # +# Some arguments are inspired by Linux' service management # +# arguments: -Start, -Stop, -Restart, -Status # +# Others are more in the Windows' style: -Setup, -Remove # +# # +# The actual start and stop operations are done when # +# running as SYSTEM, under the control of the SCM (Service # +# Control Manager). # +# # +# To create your own service, make a copy of this file and # +# rename it. The file base name becomes the service name. # +# Then implement your own service code in the if ($Service) # +# {block} at the very end of this file. See the TO DO # +# comment there. # +# There are global settings below the script param() block. # +# They can easily be changed, but the defaults should be # +# suitable for most projects. # +# # +# Service installation and usage: See the dynamic help # +# section below, or run: help .\AtomicRunnerService.ps1 -Detailed # +# # +# Debugging: The Log function writes messages into a file # +# called C:\Windows\Logs\AtomicRunnerService.log (or actually # +# ${env:windir}\Logs\$serviceName.log). # +# It is very convenient to monitor what's written into that # +# file with a WIN32 port of the Unix tail program. Usage: # +# tail -f C:\Windows\Logs\AtomicRunnerService.log # +# # +# History # +# 2015-07-10 JFL jf.larvoire@hpe.com created this script. # +# 2015-10-13 JFL Made this script completely generic, and added comments # +# in the header above. # +# 2016-01-02 JFL Moved the Event Log name into new variable $logName. # +# Improved comments. # +# 2016-01-05 JFL Fixed the StartPending state reporting. # +# 2016-03-17 JFL Removed aliases. Added missing explicit argument names. # +# 2016-04-16 JFL Moved the official repository on GitHub. # +# 2016-04-21 JFL Minor bug fix: New-EventLog did not use variable $logName.# +# 2016-05-25 JFL Bug fix: The service task was not properly stopped; Its # +# finally block was not executed, and a zombie task often # +# remained. Fixed by using a named pipe to send messages # +# to the service task. # +# 2016-06-05 JFL Finalized the event-driven service handler. # +# Fixed the default command setting in PowerShell v2. # +# Added a sample -Control option using the new pipe. # +# 2016-06-08 JFL Rewrote the pipe handler using PSThreads instead of Jobs. # +# 2016-06-09 JFL Finalized the PSThread management routines error handling.# +# This finally fixes issue #1. # +# 2016-08-22 JFL Fixed issue #3 creating the log and install directories. # +# Thanks Nischl. # +# 2016-09-06 JFL Fixed issue #4 detecting the System account. Now done in # +# a language-independent way. Thanks A Gonzalez. # +# 2016-09-19 JFL Fixed issue #5 starting services that begin with a number.# +# Added a $ServiceDescription string global setting, and # +# use it for the service registration. # +# Added comments about Windows event logs limitations. # +# 2016-11-17 RBM Fixed issue #6 Mangled hyphen in final Unregister-Event. # +# 2017-05-10 CJG Added execution policy bypass flag. # +# 2017-10-04 RBL rblindberg Updated C# code OnStop() routine fixing # +# orphaned process left after stoping the service. # +# 2017-12-05 NWK omrsafetyo Added ServiceUser and ServicePassword to the # +# script parameters. # +# 2017-12-10 JFL Removed the unreliable service account detection tests, # +# and instead use dedicated -SCMStart and -SCMStop # +# arguments in the AtomicRunnerService.exe helper app. # +# Renamed variable userName as currentUserName. # +# Renamed arguments ServiceUser and ServicePassword to the # +# more standard UserName and Password. # +# Also added the standard argument -Credential. # +# # +############################################################################### +#Requires -version 2 + +<# + .SYNOPSIS + A sample Windows service, in a standalone PowerShell script. + + .DESCRIPTION + This script demonstrates how to write a Windows service in pure PowerShell. + It dynamically generates a small AtomicRunnerService.exe wrapper, that in turn + invokes this PowerShell script again for its start and stop events. + + .PARAMETER Start + Start the service. + + .PARAMETER Stop + Stop the service. + + .PARAMETER Restart + Stop then restart the service. + + .PARAMETER Status + Get the current service status: Not installed / Stopped / Running + + .PARAMETER Setup + Install the service. + Optionally use the -Credential or -UserName arguments to specify the user + account for running the service. By default, uses the LocalSystem account. + Known limitation with the old PowerShell v2: It is necessary to use -Credential + or -UserName. For example, use -UserName LocalSystem to emulate the v3+ default. + + .PARAMETER Credential + User and password credential to use for running the service. + For use with the -Setup command. + Generate a PSCredential variable with the Get-Credential command. + + .PARAMETER UserName + User account to use for running the service. + For use with the -Setup command, in the absence of a Credential variable. + The user must have the "Log on as a service" right. To give him that right, + open the Local Security Policy management console, go to the + "\Security Settings\Local Policies\User Rights Assignments" folder, and edit + the "Log on as a service" policy there. + Services should always run using a user account which has the least amount + of privileges necessary to do its job. + Three accounts are special, and do not require a password: + * LocalSystem - The default if no user is specified. Highly privileged. + * LocalService - Very few privileges, lowest security risk. + Apparently not enough privileges for running PowerShell. Do not use. + * NetworkService - Idem, plus network access. Same problems as LocalService. + + .PARAMETER Password + Password for UserName. If not specified, you will be prompted for it. + It is strongly recommended NOT to use that argument, as that password is + visible on the console, and in the task manager list. + Instead, use the -UserName argument alone, and wait for the prompt; + or, even better, use the -Credential argument. + + .PARAMETER Remove + Uninstall the service. + + .PARAMETER Service + Run the service in the background. Used internally by the script. + Do not use, except for test purposes. + + .PARAMETER SCMStart + Process Service Control Manager start requests. Used internally by the script. + Do not use, except for test purposes. + + .PARAMETER SCMStop + Process Service Control Manager stop requests. Used internally by the script. + Do not use, except for test purposes. + + .PARAMETER Control + Send a control message to the service thread. + + .PARAMETER Version + Display this script version and exit. + + .EXAMPLE + # Setup the service and run it for the first time + C:\PS>.\AtomicRunnerService.ps1 -Status + Not installed + C:\PS>.\AtomicRunnerService.ps1 -Setup + C:\PS># At this stage, a copy of AtomicRunnerService.ps1 is present in the path + C:\PS>AtomicRunnerService -Status + Stopped + C:\PS>AtomicRunnerService -Start + C:\PS>AtomicRunnerService -Status + Running + C:\PS># Load the log file in Notepad.exe for review + C:\PS>notepad ${ENV:windir}\Logs\AtomicRunnerService.log + + .EXAMPLE + # Stop the service and uninstall it. + C:\PS>AtomicRunnerService -Stop + C:\PS>AtomicRunnerService -Status + Stopped + C:\PS>AtomicRunnerService -Remove + C:\PS># At this stage, no copy of AtomicRunnerService.ps1 is present in the path anymore + C:\PS>.\AtomicRunnerService.ps1 -Status + Not installed + + .EXAMPLE + # Configure the service to run as a different user + C:\PS>$cred = Get-Credential -UserName LAB\Assistant + C:\PS>.\AtomicRunnerService -Setup -Credential $cred + + .EXAMPLE + # Send a control message to the service, and verify that it received it. + C:\PS>AtomicRunnerService -Control Hello + C:\PS>Notepad C:\Windows\Logs\AtomicRunnerService.log + # The last lines should contain a trace of the reception of this Hello message +#> + +[CmdletBinding(DefaultParameterSetName='Status')] +Param( + [Parameter(ParameterSetName='Start', Mandatory=$true)] + [Switch]$Start, # Start the service + + [Parameter(ParameterSetName='Stop', Mandatory=$true)] + [Switch]$Stop, # Stop the service + + [Parameter(ParameterSetName='Restart', Mandatory=$true)] + [Switch]$Restart, # Restart the service + + [Parameter(ParameterSetName='Status', Mandatory=$false)] + [Switch]$Status = $($PSCmdlet.ParameterSetName -eq 'Status'), # Get the current service status + + [Parameter(ParameterSetName='Setup', Mandatory=$true)] + [Parameter(ParameterSetName='Setup2', Mandatory=$true)] + [Switch]$Setup, # Install the service + + [Parameter(ParameterSetName='Setup', Mandatory=$true)] + [String]$UserName, # Set the service to run as this user + + [Parameter(ParameterSetName='Setup', Mandatory=$false)] + [String]$Password, # Use this password for the user + + [Parameter(ParameterSetName='Setup2', Mandatory=$false)] + [System.Management.Automation.PSCredential]$Credential, # Service account credential + + [Parameter(ParameterSetName='Remove', Mandatory=$true)] + [Switch]$Remove, # Uninstall the service + + [Parameter(ParameterSetName='Service', Mandatory=$true)] + [Switch]$Service, # Run the service (Internal use only) + + [Parameter(ParameterSetName='SCMStart', Mandatory=$true)] + [Switch]$SCMStart, # Process SCM Start requests (Internal use only) + + [Parameter(ParameterSetName='SCMStop', Mandatory=$true)] + [Switch]$SCMStop, # Process SCM Stop requests (Internal use only) + + [Parameter(ParameterSetName='Control', Mandatory=$true)] + [String]$Control = $null, # Control message to send to the service + + [Parameter(ParameterSetName='Version', Mandatory=$true)] + [Switch]$Version # Get this script version +) + +$scriptVersion = "2017-12-10" + +# This script name, with various levels of details +$argv0 = Get-Item $MyInvocation.MyCommand.Definition +$script = $argv0.basename # Ex: AtomicRunnerService +$scriptName = $argv0.name # Ex: AtomicRunnerService.ps1 +$scriptFullName = $argv0.fullname # Ex: C:\Temp\AtomicRunnerService.ps1 + +# Global settings +$serviceName = $script # A one-word name used for net start commands +$serviceDisplayName = "AtomicRunnerService" +$ServiceDescription = "Executes the Invoke-KickOffAtomicRunner Script" +$pipeName = "Service_$serviceName" # Named pipe name. Used for sending messages to the service task +# $installDir = "${ENV:ProgramFiles}\$serviceName" # Where to install the service files +$installDir = "${ENV:windir}\System32" # Where to install the service files +$scriptCopy = "$installDir\$scriptName" +$exeName = "$serviceName.exe" +$exeFullName = "$installDir\$exeName" +$logDir = "${ENV:windir}\Logs" # Where to log the service messages +$logFile = "$logDir\$serviceName.log" +$logName = "Application" # Event Log name (Unrelated to the logFile!) +# Note: The current implementation only supports "classic" (ie. XP-compatble) event logs. +# To support new style (Vista and later) "Applications and Services Logs" folder trees, it would +# be necessary to use the new *WinEvent commands instead of the XP-compatible *EventLog commands. +# Gotcha: If you change $logName to "NEWLOGNAME", make sure that the registry key below does not exist: +# HKLM\System\CurrentControlSet\services\eventlog\Application\NEWLOGNAME +# Else, New-EventLog will fail, saying the log NEWLOGNAME is already registered as a source, +# even though "Get-WinEvent -ListLog NEWLOGNAME" says this log does not exist! + +# If the -Version switch is specified, display the script version and exit. +if ($Version) { + Write-Output $scriptVersion + return +} + +#-----------------------------------------------------------------------------# +# # +# Function Now # +# # +# Description Get a string with the current time. # +# # +# Notes The output string is in the ISO 8601 format, except for # +# a space instead of a T between the date and time, to # +# improve the readability. # +# # +# History # +# 2015-06-11 JFL Created this routine. # +# # +#-----------------------------------------------------------------------------# + +Function Now { + Param ( + [Switch]$ms, # Append milliseconds + [Switch]$ns # Append nanoseconds + ) + $Date = Get-Date + $now = "" + $now += "{0:0000}-{1:00}-{2:00} " -f $Date.Year, $Date.Month, $Date.Day + $now += "{0:00}:{1:00}:{2:00}" -f $Date.Hour, $Date.Minute, $Date.Second + $nsSuffix = "" + if ($ns) { + if ("$($Date.TimeOfDay)" -match "\.\d\d\d\d\d\d") { + $now += $matches[0] + $ms = $false + } else { + $ms = $true + $nsSuffix = "000" + } + } + if ($ms) { + $now += ".{0:000}$nsSuffix" -f $Date.MilliSecond + } + return $now +} + +#-----------------------------------------------------------------------------# +# # +# Function Log # +# # +# Description Log a string into the AtomicRunnerService.log file # +# # +# Arguments A string # +# # +# Notes Prefixes the string with a timestamp and the user name. # +# (Except if the string is empty: Then output a blank line.)# +# # +# History # +# 2016-06-05 JFL Also prepend the Process ID. # +# 2016-06-08 JFL Allow outputing blank lines. # +# # +#-----------------------------------------------------------------------------# + +Function Log () { + Param( + [Parameter(Mandatory=$false, ValueFromPipeline=$true, Position=0)] + [String]$string + ) + if (!(Test-Path $logDir)) { + New-Item -ItemType directory -Path $logDir | Out-Null + } + if ($String.length) { + $string = "$(Now) $pid $currentUserName $string" + } + $string | Out-File -Encoding ASCII -Append "$logFile" +} + +#-----------------------------------------------------------------------------# +# # +# Function Start-PSThread # +# # +# Description Start a new PowerShell thread # +# # +# Arguments See the Param() block # +# # +# Notes Returns a thread description object. # +# The completion can be tested in $_.Handle.IsCompleted # +# Alternative: Use a thread completion event. # +# # +# References # +# https://learn-powershell.net/tag/runspace/ # +# https://learn-powershell.net/2013/04/19/sharing-variables-and-live-objects-between-powershell-runspaces/ +# http://www.codeproject.com/Tips/895840/Multi-Threaded-PowerShell-Cookbook +# # +# History # +# 2016-06-08 JFL Created this function # +# # +#-----------------------------------------------------------------------------# + +$PSThreadCount = 0 # Counter of PSThread IDs generated so far +$PSThreadList = @{} # Existing PSThreads indexed by Id + +Function Get-PSThread () { + Param( + [Parameter(Mandatory=$false, ValueFromPipeline=$true, Position=0)] + [int[]]$Id = $PSThreadList.Keys # List of thread IDs + ) + $Id | % { $PSThreadList.$_ } +} + +Function Start-PSThread () { + Param( + [Parameter(Mandatory=$true, Position=0)] + [ScriptBlock]$ScriptBlock, # The script block to run in a new thread + [Parameter(Mandatory=$false)] + [String]$Name = "", # Optional thread name. Default: "PSThread$Id" + [Parameter(Mandatory=$false)] + [String]$Event = "", # Optional thread completion event name. Default: None + [Parameter(Mandatory=$false)] + [Hashtable]$Variables = @{}, # Optional variables to copy into the script context. + [Parameter(Mandatory=$false)] + [String[]]$Functions = @(), # Optional functions to copy into the script context. + [Parameter(Mandatory=$false)] + [Object[]]$Arguments = @() # Optional arguments to pass to the script. + ) + + $Id = $script:PSThreadCount + $script:PSThreadCount += 1 + if (!$Name.Length) { + $Name = "PSThread$Id" + } + $InitialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + foreach ($VarName in $Variables.Keys) { # Copy the specified variables into the script initial context + $value = $Variables.$VarName + Write-Debug "Adding variable $VarName=[$($Value.GetType())]$Value" + $var = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry($VarName, $value, "") + $InitialSessionState.Variables.Add($var) + } + foreach ($FuncName in $Functions) { # Copy the specified functions into the script initial context + $Body = Get-Content function:$FuncName + Write-Debug "Adding function $FuncName () {$Body}" + $func = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry($FuncName, $Body) + $InitialSessionState.Commands.Add($func) + } + $RunSpace = [RunspaceFactory]::CreateRunspace($InitialSessionState) + $RunSpace.Open() + $PSPipeline = [powershell]::Create() + $PSPipeline.Runspace = $RunSpace + $PSPipeline.AddScript($ScriptBlock) | Out-Null + $Arguments | % { + Write-Debug "Adding argument [$($_.GetType())]'$_'" + $PSPipeline.AddArgument($_) | Out-Null + } + $Handle = $PSPipeline.BeginInvoke() # Start executing the script + if ($Event.Length) { # Do this after BeginInvoke(), to avoid getting the start event. + Register-ObjectEvent $PSPipeline -EventName InvocationStateChanged -SourceIdentifier $Name -MessageData $Event + } + $PSThread = New-Object PSObject -Property @{ + Id = $Id + Name = $Name + Event = $Event + RunSpace = $RunSpace + PSPipeline = $PSPipeline + Handle = $Handle + } # Return the thread description variables + $script:PSThreadList[$Id] = $PSThread + $PSThread +} + +#-----------------------------------------------------------------------------# +# # +# Function Receive-PSThread # +# # +# Description Get the result of a thread, and optionally clean it up # +# # +# Arguments See the Param() block # +# # +# Notes # +# # +# History # +# 2016-06-08 JFL Created this function # +# # +#-----------------------------------------------------------------------------# + +Function Receive-PSThread () { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$false, ValueFromPipeline=$true, Position=0)] + [PSObject]$PSThread, # Thread descriptor object + [Parameter(Mandatory=$false)] + [Switch]$AutoRemove # If $True, remove the PSThread object + ) + Process { + if ($PSThread.Event -and $AutoRemove) { + Unregister-Event -SourceIdentifier $PSThread.Name + Get-Event -SourceIdentifier $PSThread.Name | Remove-Event # Flush remaining events + } + try { + $PSThread.PSPipeline.EndInvoke($PSThread.Handle) # Output the thread pipeline output + } catch { + $_ # Output the thread pipeline error + } + if ($AutoRemove) { + $PSThread.RunSpace.Close() + $PSThread.PSPipeline.Dispose() + $PSThreadList.Remove($PSThread.Id) + } + } +} + +Function Remove-PSThread () { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$false, ValueFromPipeline=$true, Position=0)] + [PSObject]$PSThread # Thread descriptor object + ) + Process { + $_ | Receive-PSThread -AutoRemove | Out-Null + } +} + +#-----------------------------------------------------------------------------# +# # +# Function Send-PipeMessage # +# # +# Description Send a message to a named pipe # +# # +# Arguments See the Param() block # +# # +# Notes # +# # +# History # +# 2016-05-25 JFL Created this function # +# # +#-----------------------------------------------------------------------------# + +Function Send-PipeMessage () { + Param( + [Parameter(Mandatory=$true)] + [String]$PipeName, # Named pipe name + [Parameter(Mandatory=$true)] + [String]$Message # Message string + ) + $PipeDir = [System.IO.Pipes.PipeDirection]::Out + $PipeOpt = [System.IO.Pipes.PipeOptions]::Asynchronous + + $pipe = $null # Named pipe stream + $sw = $null # Stream Writer + try { + $pipe = new-object System.IO.Pipes.NamedPipeClientStream(".", $PipeName, $PipeDir, $PipeOpt) + $sw = new-object System.IO.StreamWriter($pipe) + $pipe.Connect(1000) + if (!$pipe.IsConnected) { + throw "Failed to connect client to pipe $pipeName" + } + $sw.AutoFlush = $true + $sw.WriteLine($Message) + } catch { + Log "Error sending pipe $pipeName message: $_" + } finally { + if ($sw) { + $sw.Dispose() # Release resources + $sw = $null # Force the PowerShell garbage collector to delete the .net object + } + if ($pipe) { + $pipe.Dispose() # Release resources + $pipe = $null # Force the PowerShell garbage collector to delete the .net object + } + } +} + +#-----------------------------------------------------------------------------# +# # +# Function Receive-PipeMessage # +# # +# Description Wait for a message from a named pipe # +# # +# Arguments See the Param() block # +# # +# Notes I tried keeping the pipe open between client connections, # +# but for some reason everytime the client closes his end # +# of the pipe, this closes the server end as well. # +# Any solution on how to fix this would make the code # +# more efficient. # +# # +# History # +# 2016-05-25 JFL Created this function # +# # +#-----------------------------------------------------------------------------# + +Function Receive-PipeMessage () { + Param( + [Parameter(Mandatory=$true)] + [String]$PipeName # Named pipe name + ) + $PipeDir = [System.IO.Pipes.PipeDirection]::In + $PipeOpt = [System.IO.Pipes.PipeOptions]::Asynchronous + $PipeMode = [System.IO.Pipes.PipeTransmissionMode]::Message + + try { + $pipe = $null # Named pipe stream + $pipe = New-Object system.IO.Pipes.NamedPipeServerStream($PipeName, $PipeDir, 1, $PipeMode, $PipeOpt) + $sr = $null # Stream Reader + $sr = new-object System.IO.StreamReader($pipe) + $pipe.WaitForConnection() + $Message = $sr.Readline() + $Message + } catch { + Log "Error receiving pipe message: $_" + } finally { + if ($sr) { + $sr.Dispose() # Release resources + $sr = $null # Force the PowerShell garbage collector to delete the .net object + } + if ($pipe) { + $pipe.Dispose() # Release resources + $pipe = $null # Force the PowerShell garbage collector to delete the .net object + } + } +} + +#-----------------------------------------------------------------------------# +# # +# Function Start-PipeHandlerThread # +# # +# Description Start a new thread waiting for control messages on a pipe # +# # +# Arguments See the Param() block # +# # +# Notes The pipe handler script uses function Receive-PipeMessage.# +# This function must be copied into the thread context. # +# # +# The other functions and variables copied into that thread # +# context are not strictly necessary, but are useful for # +# debugging possible issues. # +# # +# History # +# 2016-06-07 JFL Created this function # +# # +#-----------------------------------------------------------------------------# + +$pipeThreadName = "Control Pipe Handler" + +Function Start-PipeHandlerThread () { + Param( + [Parameter(Mandatory=$true)] + [String]$pipeName, # Named pipe name + [Parameter(Mandatory=$false)] + [String]$Event = "ControlMessage" # Event message + ) + Start-PSThread -Variables @{ # Copy variables required by function Log() into the thread context + logDir = $logDir + logFile = $logFile + currentUserName = $currentUserName + } -Functions Now, Log, Receive-PipeMessage -ScriptBlock { + Param($pipeName, $pipeThreadName) + try { + Receive-PipeMessage "$pipeName" # Blocks the thread until the next message is received from the pipe + } catch { + Log "$pipeThreadName # Error: $_" + throw $_ # Push the error back to the main thread + } + } -Name $pipeThreadName -Event $Event -Arguments $pipeName, $pipeThreadName +} + +#-----------------------------------------------------------------------------# +# # +# Function Receive-PipeHandlerThread # +# # +# Description Get what the pipe handler thread received # +# # +# Arguments See the Param() block # +# # +# Notes # +# # +# History # +# 2016-06-07 JFL Created this function # +# # +#-----------------------------------------------------------------------------# + +Function Receive-PipeHandlerThread () { + Param( + [Parameter(Mandatory=$true)] + [PSObject]$pipeThread # Thread descriptor + ) + Receive-PSThread -PSThread $pipeThread -AutoRemove +} + +#-----------------------------------------------------------------------------# +# # +# Function $source # +# # +# Description C# source of the AtomicRunnerService.exe stub # +# # +# Arguments # +# # +# Notes The lines commented with "SET STATUS" and "EVENT LOG" are # +# optional. (Or blocks between "// SET STATUS [" and # +# "// SET STATUS ]" comments.) # +# SET STATUS lines are useful only for services with a long # +# startup time. # +# EVENT LOG lines are useful for debugging the service. # +# # +# History # +# 2017-10-04 RBL Updated the OnStop() procedure adding the sections # +# try{ # +# }catch{ # +# }finally{ # +# } # +# This resolved the issue where stopping the service would # +# leave the PowerShell process -Service still running. This # +# unclosed process was an orphaned process that would # +# remain until the pid was manually killed or the computer # +# was rebooted # +# # +#-----------------------------------------------------------------------------# + +$scriptCopyCname = $scriptCopy -replace "\\", "\\" # Double backslashes. (The first \\ is a regexp with \ escaped; The second is a plain string.) +$source = @" + using System; + using System.ServiceProcess; + using System.Diagnostics; + using System.Runtime.InteropServices; // SET STATUS + using System.ComponentModel; // SET STATUS + + public enum ServiceType : int { // SET STATUS [ + SERVICE_WIN32_OWN_PROCESS = 0x00000010, + SERVICE_WIN32_SHARE_PROCESS = 0x00000020, + }; // SET STATUS ] + + public enum ServiceState : int { // SET STATUS [ + SERVICE_STOPPED = 0x00000001, + SERVICE_START_PENDING = 0x00000002, + SERVICE_STOP_PENDING = 0x00000003, + SERVICE_RUNNING = 0x00000004, + SERVICE_CONTINUE_PENDING = 0x00000005, + SERVICE_PAUSE_PENDING = 0x00000006, + SERVICE_PAUSED = 0x00000007, + }; // SET STATUS ] + + [StructLayout(LayoutKind.Sequential)] // SET STATUS [ + public struct ServiceStatus { + public ServiceType dwServiceType; + public ServiceState dwCurrentState; + public int dwControlsAccepted; + public int dwWin32ExitCode; + public int dwServiceSpecificExitCode; + public int dwCheckPoint; + public int dwWaitHint; + }; // SET STATUS ] + + public enum Win32Error : int { // WIN32 errors that we may need to use + NO_ERROR = 0, + ERROR_APP_INIT_FAILURE = 575, + ERROR_FATAL_APP_EXIT = 713, + ERROR_SERVICE_NOT_ACTIVE = 1062, + ERROR_EXCEPTION_IN_SERVICE = 1064, + ERROR_SERVICE_SPECIFIC_ERROR = 1066, + ERROR_PROCESS_ABORTED = 1067, + }; + + public class Service_$serviceName : ServiceBase { // $serviceName may begin with a digit; The class name must begin with a letter + private System.Diagnostics.EventLog eventLog; // EVENT LOG + private ServiceStatus serviceStatus; // SET STATUS + + public Service_$serviceName() { + ServiceName = "$serviceName"; + CanStop = true; + CanPauseAndContinue = false; + AutoLog = true; + + eventLog = new System.Diagnostics.EventLog(); // EVENT LOG [ + if (!System.Diagnostics.EventLog.SourceExists(ServiceName)) { + System.Diagnostics.EventLog.CreateEventSource(ServiceName, "$logName"); + } + eventLog.Source = ServiceName; + eventLog.Log = "$logName"; // EVENT LOG ] + EventLog.WriteEntry(ServiceName, "$exeName $serviceName()"); // EVENT LOG + } + + [DllImport("advapi32.dll", SetLastError=true)] // SET STATUS + private static extern bool SetServiceStatus(IntPtr handle, ref ServiceStatus serviceStatus); + + protected override void OnStart(string [] args) { + EventLog.WriteEntry(ServiceName, "$exeName OnStart() // Entry. Starting script '$scriptCopyCname' -SCMStart"); // EVENT LOG + // Set the service state to Start Pending. // SET STATUS [ + // Only useful if the startup time is long. Not really necessary here for a 2s startup time. + serviceStatus.dwServiceType = ServiceType.SERVICE_WIN32_OWN_PROCESS; + serviceStatus.dwCurrentState = ServiceState.SERVICE_START_PENDING; + serviceStatus.dwWin32ExitCode = 0; + serviceStatus.dwWaitHint = 2000; // It takes about 2 seconds to start PowerShell + SetServiceStatus(ServiceHandle, ref serviceStatus); // SET STATUS ] + // Start a child process with another copy of this script + try { + Process p = new Process(); + // Redirect the output stream of the child process. + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardOutput = true; + p.StartInfo.FileName = "PowerShell.exe"; + p.StartInfo.Arguments = "-ExecutionPolicy Bypass -c & '$scriptCopyCname' -SCMStart"; // Works if path has spaces, but not if it contains ' quotes. + p.Start(); + // Read the output stream first and then wait. (To avoid deadlocks says Microsoft!) + string output = p.StandardOutput.ReadToEnd(); + // Wait for the completion of the script startup code, that launches the -Service instance + p.WaitForExit(); + if (p.ExitCode != 0) throw new Win32Exception((int)(Win32Error.ERROR_APP_INIT_FAILURE)); + // Success. Set the service state to Running. // SET STATUS + serviceStatus.dwCurrentState = ServiceState.SERVICE_RUNNING; // SET STATUS + } catch (Exception e) { + EventLog.WriteEntry(ServiceName, "$exeName OnStart() // Failed to start $scriptCopyCname. " + e.Message, EventLogEntryType.Error); // EVENT LOG + // Change the service state back to Stopped. // SET STATUS [ + serviceStatus.dwCurrentState = ServiceState.SERVICE_STOPPED; + Win32Exception w32ex = e as Win32Exception; // Try getting the WIN32 error code + if (w32ex == null) { // Not a Win32 exception, but maybe the inner one is... + w32ex = e.InnerException as Win32Exception; + } + if (w32ex != null) { // Report the actual WIN32 error + serviceStatus.dwWin32ExitCode = w32ex.NativeErrorCode; + } else { // Make up a reasonable reason + serviceStatus.dwWin32ExitCode = (int)(Win32Error.ERROR_APP_INIT_FAILURE); + } // SET STATUS ] + } finally { + serviceStatus.dwWaitHint = 0; // SET STATUS + SetServiceStatus(ServiceHandle, ref serviceStatus); // SET STATUS + EventLog.WriteEntry(ServiceName, "$exeName OnStart() // Exit"); // EVENT LOG + } + } + + protected override void OnStop() { + EventLog.WriteEntry(ServiceName, "$exeName OnStop() // Entry"); // EVENT LOG + // Start a child process with another copy of ourselves + try { + Process p = new Process(); + // Redirect the output stream of the child process. + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardOutput = true; + p.StartInfo.FileName = "PowerShell.exe"; + p.StartInfo.Arguments = "-ExecutionPolicy Bypass -c & '$scriptCopyCname' -SCMStop"; // Works if path has spaces, but not if it contains ' quotes. + p.Start(); + // Read the output stream first and then wait. (To avoid deadlocks says Microsoft!) + string output = p.StandardOutput.ReadToEnd(); + // Wait for the PowerShell script to be fully stopped. + p.WaitForExit(); + if (p.ExitCode != 0) throw new Win32Exception((int)(Win32Error.ERROR_APP_INIT_FAILURE)); + // Success. Set the service state to Stopped. // SET STATUS + serviceStatus.dwCurrentState = ServiceState.SERVICE_STOPPED; // SET STATUS + } catch (Exception e) { + EventLog.WriteEntry(ServiceName, "$exeName OnStop() // Failed to stop $scriptCopyCname. " + e.Message, EventLogEntryType.Error); // EVENT LOG + // Change the service state back to Started. // SET STATUS [ + serviceStatus.dwCurrentState = ServiceState.SERVICE_RUNNING; + Win32Exception w32ex = e as Win32Exception; // Try getting the WIN32 error code + if (w32ex == null) { // Not a Win32 exception, but maybe the inner one is... + w32ex = e.InnerException as Win32Exception; + } + if (w32ex != null) { // Report the actual WIN32 error + serviceStatus.dwWin32ExitCode = w32ex.NativeErrorCode; + } else { // Make up a reasonable reason + serviceStatus.dwWin32ExitCode = (int)(Win32Error.ERROR_APP_INIT_FAILURE); + } // SET STATUS ] + } finally { + serviceStatus.dwWaitHint = 0; // SET STATUS + SetServiceStatus(ServiceHandle, ref serviceStatus); // SET STATUS + EventLog.WriteEntry(ServiceName, "$exeName OnStop() // Exit"); // EVENT LOG + } + } + + public static void Main() { + System.ServiceProcess.ServiceBase.Run(new Service_$serviceName()); + } + } +"@ + +#-----------------------------------------------------------------------------# +# # +# Function Main # +# # +# Description Execute the specified actions # +# # +# Arguments See the Param() block at the top of this script # +# # +# Notes # +# # +# History # +# # +#-----------------------------------------------------------------------------# + +# Identify the user name. We use that for logging. +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$currentUserName = $identity.Name # Ex: "NT AUTHORITY\SYSTEM" or "Domain\Administrator" + +if ($Setup) {Log ""} # Insert one blank line to separate test sessions logs +Log $MyInvocation.Line # The exact command line that was used to start us + +# The following commands write to the event log, but we need to make sure the AtomicRunnerService source is defined. +New-EventLog -LogName $logName -Source $serviceName -ea SilentlyContinue + +# Workaround for PowerShell v2 bug: $PSCmdlet Not yet defined in Param() block +$Status = ($PSCmdlet.ParameterSetName -eq 'Status') + +if ($SCMStart) { # The SCM tells us to start the service + # Do whatever is necessary to start the service script instance + Log "$scriptName -SCMStart: Starting script '$scriptFullName' -Service" + Write-EventLog -LogName $logName -Source $serviceName -EventId 1001 -EntryType Information -Message "$scriptName -SCMStart: Starting script '$scriptFullName' -Service" + Start-Process PowerShell.exe -ArgumentList ("-c & '$scriptFullName' -Service") + return +} + +if ($Start) { # The user tells us to start the service + Write-Verbose "Starting service $serviceName" + Write-EventLog -LogName $logName -Source $serviceName -EventId 1002 -EntryType Information -Message "$scriptName -Start: Starting service $serviceName" + Start-Service $serviceName # Ask Service Control Manager to start it + return +} + +if ($SCMStop) { # The SCM tells us to stop the service + # Do whatever is necessary to stop the service script instance + Write-EventLog -LogName $logName -Source $serviceName -EventId 1003 -EntryType Information -Message "$scriptName -SCMStop: Stopping script $scriptName -Service" + Log "$scriptName -SCMStop: Stopping script $scriptName -Service" + # Send an exit message to the service instance + Send-PipeMessage $pipeName "exit" + return +} + +if ($Stop) { # The user tells us to stop the service + Write-Verbose "Stopping service $serviceName" + Write-EventLog -LogName $logName -Source $serviceName -EventId 1004 -EntryType Information -Message "$scriptName -Stop: Stopping service $serviceName" + Stop-Service $serviceName # Ask Service Control Manager to stop it + return +} + +if ($Restart) { # Restart the service + & $scriptFullName -Stop + & $scriptFullName -Start + return +} + +if ($Status) { # Get the current service status + $spid = $null + $processes = @(Get-WmiObject Win32_Process -filter "Name = 'powershell.exe'" | Where-Object { + $_.CommandLine -match ".*$scriptCopyCname.*-Service" + }) + foreach ($process in $processes) { # There should be just one, but be prepared for surprises. + $spid = $process.ProcessId + Write-Verbose "$serviceName Process ID = $spid" + } + # if (Test-Path "HKLM:\SYSTEM\CurrentControlSet\services\$serviceName") {} + try { + $pss = Get-Service $serviceName -ea stop # Will error-out if not installed + } catch { + "Not Installed" + return + } + $pss.Status + if (($pss.Status -eq "Running") -and (!$spid)) { # This happened during the debugging phase + Write-Error "The Service Control Manager thinks $serviceName is started, but $serviceName.ps1 -Service is not running." + exit 1 + } + return +} + +if ($Setup) { # Install the service + # Check if it's necessary + try { + $pss = Get-Service $serviceName -ea stop # Will error-out if not installed + # Check if this script is newer than the installed copy. + if ((Get-Item $scriptCopy -ea SilentlyContinue).LastWriteTime -lt (Get-Item $scriptFullName -ea SilentlyContinue).LastWriteTime) { + Write-Verbose "Service $serviceName is already Installed, but requires upgrade" + & $scriptFullName -Remove + throw "continue" + } else { + Write-Verbose "Service $serviceName is already Installed, and up-to-date" + } + exit 0 + } catch { + # This is the normal case here. Do not throw or write any error! + Write-Debug "Installation is necessary" # Also avoids a ScriptAnalyzer warning + # And continue with the installation. + } + if (!(Test-Path $installDir)) { + New-Item -ItemType directory -Path $installDir | Out-Null + } + # Copy the service script into the installation directory + if ($ScriptFullName -ne $scriptCopy) { + Write-Verbose "Installing $scriptCopy" + Copy-Item $ScriptFullName $scriptCopy + } + # Generate the service .EXE from the C# source embedded in this script + try { + Write-Verbose "Compiling $exeFullName" + Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $exeFullName -OutputType ConsoleApplication -ReferencedAssemblies "System.ServiceProcess" -Debug:$false + } catch { + $msg = $_.Exception.Message + Write-error "Failed to create the $exeFullName service stub. $msg" + exit 1 + } + # Register the service + Write-Verbose "Registering service $serviceName" + if ($UserName -and !$Credential.UserName) { + $emptyPassword = New-Object -Type System.Security.SecureString + switch ($UserName) { + {"LocalService", "NetworkService" -contains $_} { + $Credential = New-Object -Type System.Management.Automation.PSCredential ("NT AUTHORITY\$UserName", $emptyPassword) + } + {"LocalSystem", ".\LocalSystem", "${env:COMPUTERNAME}\LocalSystem", "NT AUTHORITY\LocalService", "NT AUTHORITY\NetworkService" -contains $_} { + $Credential = New-Object -Type System.Management.Automation.PSCredential ($UserName, $emptyPassword) + } + default { + if (!$Password) { + $Credential = Get-Credential -UserName $UserName -Message "Please enter the password for the service user" + } else { + $securePassword = ConvertTo-SecureString $Password -AsPlainText -Force + $Credential = New-Object -Type System.Management.Automation.PSCredential ($UserName, $securePassword) + } + } + } + } + if ($Credential.UserName) { + Log "$scriptName -Setup # Configuring the service to run as $($Credential.UserName)" + $pss = New-Service $serviceName $exeFullName -DisplayName $serviceDisplayName -Description $ServiceDescription -StartupType Automatic -Credential $Credential + } else { + Log "$scriptName -Setup # Configuring the service to run by default as LocalSystem" + $pss = New-Service $serviceName $exeFullName -DisplayName $serviceDisplayName -Description $ServiceDescription -StartupType Automatic + } + + return +} + +if ($Remove) { # Uninstall the service + # Check if it's necessary + try { + $pss = Get-Service $serviceName -ea stop # Will error-out if not installed + } catch { + Write-Verbose "Already uninstalled" + return + } + Stop-Service $serviceName # Make sure it's stopped + # In the absence of a Remove-Service applet, use sc.exe instead. + Write-Verbose "Removing service $serviceName" + $msg = sc.exe delete $serviceName + if ($LastExitCode) { + Write-Error "Failed to remove the service ${serviceName}: $msg" + exit 1 + } else { + Write-Verbose $msg + } + # Remove the installed files + if (Test-Path $installDir) { + foreach ($ext in ("exe", "pdb", "ps1")) { + $file = "$installDir\$serviceName.$ext" + if (Test-Path $file) { + Write-Verbose "Deleting file $file" + Remove-Item $file + } + } + if (!(@(Get-ChildItem $installDir -ea SilentlyContinue)).Count) { + Write-Verbose "Removing directory $installDir" + Remove-Item $installDir + } + } + Log "" # Insert one blank line to separate test sessions logs + return +} + +if ($Control) { # Send a control message to the service + Send-PipeMessage $pipeName $control +} + +if ($Service) { # Run the service + Write-EventLog -LogName $logName -Source $serviceName -EventId 1005 -EntryType Information -Message "$scriptName -Service # Beginning background job" + # Do the service background job + try { + # Start the control pipe handler thread + $pipeThread = Start-PipeHandlerThread $pipeName -Event "ControlMessage" + + ######### TO DO: Implement your own service code here. ########## + # Now enter the main service event loop + do { # Keep running until told to exit by the -Stop handler + . $profile + Invoke-KickoffAtomicRunner + $event = Wait-Event # Wait for the next incoming event + $source = $event.SourceIdentifier + $message = $event.MessageData + $eventTime = $event.TimeGenerated.TimeofDay + Write-Debug "Event at $eventTime from ${source}: $message" + $event | Remove-Event # Flush the event from the queue + switch ($message) { + "ControlMessage" { # Required. Message received by the control pipe thread + $state = $event.SourceEventArgs.InvocationStateInfo.state + Write-Debug "$script -Service # Thread $source state changed to $state" + switch ($state) { + "Completed" { + $message = Receive-PipeHandlerThread $pipeThread + Log "$scriptName -Service # Received control message: $Message" + if ($message -ne "exit") { # Start another thread waiting for control messages + $pipeThread = Start-PipeHandlerThread $pipeName -Event "ControlMessage" + } + } + "Failed" { + $error1 = Receive-PipeHandlerThread $pipeThread + Log "$scriptName -Service # $source thread failed: $error1" + Start-Sleep 1 # Avoid getting too many errors + $pipeThread = Start-PipeHandlerThread $pipeName -Event "ControlMessage" # Retry + } + } + } + default { # Should not happen + Log "$scriptName -Service # Unexpected event from ${source}: $Message" + } + } + } while ($message -ne "exit") + } catch { # An exception occurred while runnning the service + $msg = $_.Exception.Message + $line = $_.InvocationInfo.ScriptLineNumber + Log "$scriptName -Service # Error at line ${line}: $msg" + } finally { # Invoked in all cases: Exception or normally by -Stop + ############### End of the service code example. ################ + + # Terminate the control pipe handler thread + Get-PSThread | Remove-PSThread # Remove all remaining threads + # Flush all leftover events (There may be some that arrived after we exited the while event loop, but before we unregistered the events) + $events = Get-Event | Remove-Event + # Log a termination event, no matter what the cause is. + Write-EventLog -LogName $logName -Source $serviceName -EventId 1006 -EntryType Information -Message "$script -Service # Exiting" + Log "$scriptName -Service # Exiting" + } + return +} \ No newline at end of file diff --git a/Public/Invoke-SetupAtomicRunner.ps1 b/Public/Invoke-SetupAtomicRunner.ps1 index da683dc..d2fd77b 100755 --- a/Public/Invoke-SetupAtomicRunner.ps1 +++ b/Public/Invoke-SetupAtomicRunner.ps1 @@ -21,66 +21,14 @@ function Invoke-SetupAtomicRunner { New-Item -ItemType Directory $artConfig.atomicLogsPath -ErrorAction Ignore New-Item -ItemType Directory $artConfig.runnerFolder -ErrorAction Ignore - if ($artConfig.gmsaAccount) { - Start-Service WinRM - $path = Join-Path $env:ProgramFiles "WindowsPowerShell\Modules\RenameRunner\RoleCapabilities" - New-Item -ItemType Directory $path -ErrorAction Ignore - New-PSSessionConfigurationFile -SessionType RestrictedRemoteServer -GroupManagedServiceAccount $artConfig.gmsaAccount -RoleDefinitions @{ "$($artConfig.user)" = @{ 'RoleCapabilities' = 'RenameRunner' } } -path "$env:Temp\RenameRunner.pssc" - New-PSRoleCapabilityFile -VisibleCmdlets @{ 'Name' = 'Rename-Computer'; 'Parameters' = @{ 'Name' = 'NewName'; 'ValidatePattern' = 'ATOMICSOC.*' }, @{ 'Name' = 'Force' }, @{ 'Name' = 'restart' } } -path "$path\RenameRunner.psrc" - $null = Register-PSSessionConfiguration -name "RenameRunnerEndpoint" -path "$env:Temp\RenameRunner.pssc" -force - Add-LocalGroupMember "administrators" "$($artConfig.gmsaAccount)$" -ErrorAction Ignore - # Make sure WinRM is enabled and set to Automic start (not delayed) - Set-ItemProperty hklm:\\SYSTEM\CurrentControlSet\Services\WinRM -Name Start -Value 2 - Set-ItemProperty hklm:\\SYSTEM\CurrentControlSet\Services\WinRM -Name DelayedAutostart -Value 0 # default is delayed start and that is too slow given our 1 minute delay on our kickoff task - # this registry key must be set to zero for things to work get-itemproperty hklm:\Software\Policies\Microsoft\Windows\WinRM\Service\ - $hklmKey = (get-itemproperty hklm:\Software\Policies\Microsoft\Windows\WinRM\Service -name DisableRunAs -ErrorAction ignore).DisableRunAs - $hkcuKey = (get-itemproperty hkcu:\Software\Policies\Microsoft\Windows\WinRM\Service -name DisableRunAs -ErrorAction ignore).DisableRunAs - if ((1 -eq $hklmKey) -or (1 -eq $hkcuKey)) { Write-Host -ForegroundColor Red "DisableRunAs registry Key will not allow use of the JEA endpoint with a gmsa account" } - if ((Get-ItemProperty hklm:\System\CurrentControlSet\Control\Lsa\ -name DisableDomainCreds).DisableDomainCreds) { Write-Host -ForegroundColor Red "Do not allow storage of passwords and credentials for network authentication must be disabled" } - } - if ($artConfig.OS -eq "windows") { - if (Test-Path $artConfig.credFile) { - Write-Host "Credential File $($artConfig.credFile) already exists, not prompting for creation of a new one." - $cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $artConfig.user, (Get-Content $artConfig.credFile | ConvertTo-SecureString) - } - else { - # create credential file for the user since we aren't using a group managed service account - $cred = Get-Credential -UserName $artConfig.user -message "Enter password for $($artConfig.user) in order to create the runner scheduled task" - $cred.Password | ConvertFrom-SecureString | Out-File $artConfig.credFile - - } - - # setup scheduled task that will start the runner after each restart - # local security policy --> Local Policies --> Security Options --> Network access: Do not allow storage of passwords and credentials for network authentication must be disabled - $taskName = "KickOff-AtomicRunner" - Unregister-ScheduledTask $taskName -confirm:$false -ErrorAction Ignore - # Windows scheduled task includes a 20 minutes sleep then restart if the call to Invoke-KickoffAtomicRunner fails - # this occurs occassional when Windows has issues logging into the runner user's account and logs in as a TEMP user - $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-exec bypass -Command Invoke-KickoffAtomicRunner; Start-Sleep 1200; Restart-Computer -Force" - $taskPrincipal = New-ScheduledTaskPrincipal -UserId $artConfig.user - $delays = @(1, 2, 4, 8, 16, 32, 64) # using multiple triggers as a retry mechanism because the built-in retry mechanism doesn't work when the computer renaming causes AD replication delays - $triggers = @() - foreach ($delay in $delays) { - $trigger = New-ScheduledTaskTrigger -AtStartup - $trigger.Delay = "PT$delay`M" - $triggers += $trigger - } - $task = New-ScheduledTask -Action $taskAction -Principal $taskPrincipal -Trigger $triggers -Description "A task that runs 1 minute or later after boot to start the atomic test runner script" - try { - $null = Register-ScheduledTask -TaskName $taskName -InputObject $task -User $artConfig.user -Password $($cred.GetNetworkCredential().password) -ErrorAction Stop - } - catch { - if ($_.CategoryInfo.Category -eq "AuthenticationError") { - # remove the credential file if the password didn't work - Write-Error "The credentials you entered are incorrect. Please run the setup script again and double check the username and password." - Remove-Item $artConfig.credFile - } - else { - Throw $_ - } - } + # create the service that will start the runner after each restart + # The user must have the "Log on as a service" right. To give him that right, open the Local Security Policy management console, go to the + # "\Security Settings\Local Policies\User Rights Assignments" folder, and edit the "Log on as a service" policy there. + . "$PSScriptRoot\AtomicRunnerService.ps1" -Remove + . "$PSScriptRoot\AtomicRunnerService.ps1" -UserName $artConfig.user -Setup + AtomicRunnerService -Start } else { # sets cronjob string using basepath from config.ps1 From 9f4139f453dd72dc23118c82fe359cee22e51974 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Tue, 19 Mar 2024 16:46:30 -0500 Subject: [PATCH 02/19] poc: atomic runner service --- Invoke-AtomicRedTeam.psm1 | 2 +- Public/Invoke-SetupAtomicRunner.ps1 | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Invoke-AtomicRedTeam.psm1 b/Invoke-AtomicRedTeam.psm1 index 9ba5c34..eac897e 100644 --- a/Invoke-AtomicRedTeam.psm1 +++ b/Invoke-AtomicRedTeam.psm1 @@ -6,7 +6,7 @@ if([bool]$artConfig.absb -and ($artConfig.OS -eq "windows")){ } #Get public and private function definition files. -$Public = @( Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -Recurse -ErrorAction SilentlyContinue ) +$Public = @( Get-ChildItem -Path $PSScriptRoot\Public\*.ps1 -Recurse -Exclude AtomicRunnerService.ps1 -ErrorAction SilentlyContinue ) $Private = @( Get-ChildItem -Path $PSScriptRoot\Private\*.ps1 -Recurse -Exclude "AtomicClassSchema.ps1" -ErrorAction SilentlyContinue ) # Make sure the Atomic Class Schema is available first (a workaround so PSv5.0 doesn't give errors) diff --git a/Public/Invoke-SetupAtomicRunner.ps1 b/Public/Invoke-SetupAtomicRunner.ps1 index d2fd77b..479dacc 100755 --- a/Public/Invoke-SetupAtomicRunner.ps1 +++ b/Public/Invoke-SetupAtomicRunner.ps1 @@ -29,6 +29,9 @@ function Invoke-SetupAtomicRunner { . "$PSScriptRoot\AtomicRunnerService.ps1" -Remove . "$PSScriptRoot\AtomicRunnerService.ps1" -UserName $artConfig.user -Setup AtomicRunnerService -Start + + # remove scheduled task now that we are using a service instead + Unregister-ScheduledTask "KickOff-AtomicRunner" -confirm:$false -ErrorAction Ignore } else { # sets cronjob string using basepath from config.ps1 From 10265d5e613ad44ec09398f7e21584ca244dea35 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Fri, 22 Mar 2024 15:25:16 -0500 Subject: [PATCH 03/19] add service setup --- Public/AtomicRunnerService.ps1 | 16 ++++++---- Public/Invoke-AtomicRunner.ps1 | 43 ++++++++++++++++----------- Public/Invoke-KickoffAtomicRunner.ps1 | 24 +++++++++++---- Public/Invoke-SetupAtomicRunner.ps1 | 30 +++++++++++++++---- 4 files changed, 80 insertions(+), 33 deletions(-) diff --git a/Public/AtomicRunnerService.ps1 b/Public/AtomicRunnerService.ps1 index f5fcbc3..3c46fab 100644 --- a/Public/AtomicRunnerService.ps1 +++ b/Public/AtomicRunnerService.ps1 @@ -261,7 +261,6 @@ Param( [Parameter(ParameterSetName='Version', Mandatory=$true)] [Switch]$Version # Get this script version ) - $scriptVersion = "2017-12-10" # This script name, with various levels of details @@ -901,7 +900,14 @@ if ($SCMStart) { # The SCM tells us to start the service if ($Start) { # The user tells us to start the service Write-Verbose "Starting service $serviceName" Write-EventLog -LogName $logName -Source $serviceName -EventId 1002 -EntryType Information -Message "$scriptName -Start: Starting service $serviceName" - Start-Service $serviceName # Ask Service Control Manager to start it + try { + Start-Service $serviceName -ErrorAction Stop # Ask Service Control Manager to start it + } + catch { + $_ + Write-Host -ForegroundColor Red "Remember, the user '$($artConfig.user)' must have the ""Log on as a service"" right. To add that right, open the Local Security Policy management console, go to the ""\Security Settings\Local Policies\User Rights Assignments"" folder, and edit the ""Log on as a service"" policy there." + Write-Host -ForegroundColor Yellow "If you already have the rights set, then you probably entered the wrong password for the user. Try running the Invoke-SetupAtomicRunner script again and entering the correct password" + } return } @@ -1066,10 +1072,10 @@ if ($Service) { # Run the service $pipeThread = Start-PipeHandlerThread $pipeName -Event "ControlMessage" ######### TO DO: Implement your own service code here. ########## + . $profile + Invoke-KickoffAtomicRunner # Now enter the main service event loop do { # Keep running until told to exit by the -Stop handler - . $profile - Invoke-KickoffAtomicRunner $event = Wait-Event # Wait for the next incoming event $source = $event.SourceIdentifier $message = $event.MessageData @@ -1107,7 +1113,7 @@ if ($Service) { # Run the service Log "$scriptName -Service # Error at line ${line}: $msg" } finally { # Invoked in all cases: Exception or normally by -Stop ############### End of the service code example. ################ - + # Terminate the control pipe handler thread Get-PSThread | Remove-PSThread # Remove all remaining threads # Flush all leftover events (There may be some that arrived after we exited the while event loop, but before we unregistered the events) diff --git a/Public/Invoke-AtomicRunner.ps1 b/Public/Invoke-AtomicRunner.ps1 index a550d1e..8038f0c 100755 --- a/Public/Invoke-AtomicRunner.ps1 +++ b/Public/Invoke-AtomicRunner.ps1 @@ -37,11 +37,14 @@ function Invoke-AtomicRunner { [ValidateRange(0, [int]::MaxValue)] [int] $PauseBetweenAtomics, + [parameter(Mandatory = $false)] + [switch] $scheduledTaskCleanup, + [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)] $OtherArgs ) Begin { } - Process { + Process { function Get-GuidFromHostName( $basehostname ) { $guid = [System.Net.Dns]::GetHostName() -replace $($basehostname + "-"), "" @@ -50,7 +53,7 @@ function Invoke-AtomicRunner { LogRunnerMsg "Hostname has not been updated or could not parse out the Guid: " + $guid return } - + # Confirm hostname contains a guid [regex]$guidRegex = '(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$' @@ -139,9 +142,9 @@ function Invoke-AtomicRunner { } exit } - + } - + function Get-TimingVariable ($sched) { $atcount = $sched.Count if ($null -eq $atcount) { $atcount = 1 } @@ -151,7 +154,7 @@ function Invoke-AtomicRunner { if ($sleeptime -lt 120) { $sleeptime = 120 } # minimum 2 minute sleep time return $sleeptime } - + # Convert OtherArgs to hashtable so we can pass it through to the call to Invoke-AtomicTest $htvars = @{} if ($OtherArgs) { @@ -178,6 +181,7 @@ function Invoke-AtomicRunner { $htvars.Remove('OtherArgs') | Out-Null $htvars.Remove('Cleanup') | Out-Null $htvars.Remove('PauseBetweenAtomics') | Out-Null + $htvars.Remove('scheduledTaskCleanup') | Out-Null $schedule = Get-Schedule $listOfAtomics # If the schedule is empty, end process @@ -203,7 +207,7 @@ function Invoke-AtomicRunner { Write-Host -ForegroundColor Yellow "Exiting script because $($artConfig.stopFile) does exist."; Start-Sleep 10; exit } - + # Find current test to run $guid = Get-GuidFromHostName $artConfig.basehostname if ([string]::IsNullOrWhiteSpace($guid)) { @@ -218,11 +222,16 @@ function Invoke-AtomicRunner { } if ($null -ne $tr) { - # run the atomic test and exit - Invoke-AtomicTestFromScheduleRow $tr - # Cleanup after running test - Write-Host -Fore cyan "Sleeping for $SleepTillCleanup seconds before cleaning up for $($tr.Technique) $($tr.auto_generated_guid) "; Start-Sleep -Seconds $SleepTillCleanup - Invoke-AtomicTestFromScheduleRow $tr $true + if ($scheduledTaskCleanup) { + # Cleanup after running test + Write-Host -Fore cyan "Sleeping for $SleepTillCleanup seconds before cleaning up for $($tr.Technique) $($tr.auto_generated_guid) "; Start-Sleep -Seconds $SleepTillCleanup + Invoke-AtomicTestFromScheduleRow $tr $true + } + else { + # run the atomic test and exit + Invoke-AtomicTestFromScheduleRow $tr + Start-Sleep 3; exit + } } else { LogRunnerMsg "Could not find Test: $guid in schedule. Please update schedule to run this test." @@ -230,21 +239,21 @@ function Invoke-AtomicRunner { } # Load next scheduled test before renaming computer - $nextIndex += $currentIndex + 1 + $nextIndex += $currentIndex + 1 if ($nextIndex -ge ($schedule.count)) { $tr = $schedule[0] } else { $tr = $schedule[$nextIndex] } - - if ($null -eq $tr) { - LogRunnerMsg "Could not determine the next row to execute from the schedule, Starting from 1st row"; - $tr = $schedule[0] + + if ($null -eq $tr) { + LogRunnerMsg "Could not determine the next row to execute from the schedule, Starting from 1st row"; + $tr = $schedule[0] } #Rename Computer and Restart Rename-ThisComputer $tr $artConfig.basehostname - + } } \ No newline at end of file diff --git a/Public/Invoke-KickoffAtomicRunner.ps1 b/Public/Invoke-KickoffAtomicRunner.ps1 index d495ce1..a34c028 100644 --- a/Public/Invoke-KickoffAtomicRunner.ps1 +++ b/Public/Invoke-KickoffAtomicRunner.ps1 @@ -6,16 +6,16 @@ function Invoke-KickoffAtomicRunner { $datetime = Get-Date -uformat "%Y-%m-%d-%H%M" $log = Get-Item $logPath - if ($log.Length / 1MB -ge $max_filesize) { + if ($log.Length / 1MB -ge $max_filesize) { Write-Host "file named $($log.name) is bigger than $max_filesize MB" $newname = "$($log.Name)_${datetime}.arclog" Rename-Item $log.PSPath $newname - Write-Host "Done rotating file" + Write-Host "Done rotating file" } $logdir_content = Get-ChildItem $artConfig.atomicLogsPath -filter "*.arclog" $cutoff_date = (get-date).AddDays($max_age) - $logdir_content | ForEach-Object { + $logdir_content | ForEach-Object { if ($_.LastWriteTime -gt $cutoff_date) { Remove-Item $_ Write-Host "Removed $($_.PSPath)" @@ -25,19 +25,31 @@ function Invoke-KickoffAtomicRunner { #Create log files as needed $all_log_file = Join-Path $artConfig.atomicLogsPath "all-out-$($artConfig.basehostname).txt" - New-Item $all_log_file -ItemType file -ErrorAction Ignore - New-Item $artConfig.logFile -ItemType File -ErrorAction Ignore + $all_log_file_cleanup = Join-Path $artConfig.atomicLogsPath "all-out-$($artConfig.basehostname)-cleanup.txt" + New-Item $all_log_file -ItemType file -ErrorAction Ignore + New-Item $all_log_file_cleanup -ItemType file -ErrorAction Ignore + New-Item $artConfig.logFile -ItemType File -ErrorAction Ignore #Rotate logs based on FileSize and Date max_filesize $max_filesize = 200 #in MB $max_file_age = 30 #in days Rotate-Log $all_log_file $max_filesize $max_file_age + Rotate-Log $all_log_file_cleanup $max_filesize $max_file_age + Rotate-Log $artConfig.logFile $max_filesize $max_file_age #no need to repeat this. Can reduce further. # Optional additional delay before starting Start-Sleep $artConfig.kickOffDelay.TotalSeconds - if ($artConfig.debug) { Invoke-AtomicRunner *>> $all_log_file } else { Invoke-AtomicRunner } + $WorkingDirectory = if ($IsLinux -or $IsMacOS) { "/tmp" } else { $env:TEMP } + $FileName = if ($IsLinux -or $IsMacOS) { "pwsh" } else { "powershell.exe" } + if ($artConfig.debug) { $Arguments = "-Command Invoke-AtomicRunner *>> $all_log_file" } else { $Arguments = "-Command Invoke-AtomicRunner" } + # Invoke the atomic as its own process because we don't want to skip the cleanup and rename process in the event that AV kills the process running the atomic + Start-Process -FilePath $FileName -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory + + if ($artConfig.debug) { $Arguments = "-Command Invoke-AtomicRunner -scheduledTaskCleanup *>> $all_log_file_cleanup" } else { $Arguments = "-Command Invoke-AtomicRunner -scheduledTaskCleanup" } + Start-Process -FilePath $FileName -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory + } function LogRunnerMsg ($message) { diff --git a/Public/Invoke-SetupAtomicRunner.ps1 b/Public/Invoke-SetupAtomicRunner.ps1 index 479dacc..c08a184 100755 --- a/Public/Invoke-SetupAtomicRunner.ps1 +++ b/Public/Invoke-SetupAtomicRunner.ps1 @@ -1,15 +1,18 @@ function Invoke-SetupAtomicRunner { # ensure running with admin privs - if ($artConfig.OS -eq "windows") { # auto-elevate on Windows + if ($artConfig.OS -eq "windows") { + # auto-elevate on Windows $currentUser = New-Object Security.Principal.WindowsPrincipal $([Security.Principal.WindowsIdentity]::GetCurrent()) $testadmin = $currentUser.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) if ($testadmin -eq $false) { Start-Process powershell.exe -Verb RunAs -ArgumentList ('-noprofile -noexit -file "{0}" -elevated' -f ($myinvocation.MyCommand.Definition)) exit $LASTEXITCODE } - } else { # linux and macos check - doesn't auto-elevate - if((id -u) -ne 0 ){ + } + else { + # linux and macos check - doesn't auto-elevate + if ((id -u) -ne 0 ) { Throw "You must run the Invoke-SetupAtomicRunner script as root" exit } @@ -24,11 +27,28 @@ function Invoke-SetupAtomicRunner { if ($artConfig.OS -eq "windows") { # create the service that will start the runner after each restart - # The user must have the "Log on as a service" right. To give him that right, open the Local Security Policy management console, go to the + # The user must have the "Log on as a service" right. To add that right, open the Local Security Policy management console, go to the # "\Security Settings\Local Policies\User Rights Assignments" folder, and edit the "Log on as a service" policy there. . "$PSScriptRoot\AtomicRunnerService.ps1" -Remove . "$PSScriptRoot\AtomicRunnerService.ps1" -UserName $artConfig.user -Setup - AtomicRunnerService -Start + # set service start retry options + $ServiceDisplayName = "AtomicRunnerService" + $action1, $action2, $action3 = "restart" + $time1 = 600000 # 10 minutes in miliseconds + $action2 = "restart" + $time2 = 600000 # 10 minutes in miliseconds + $actionLast = "restart" + $timeLast = 3600000 # 1 hour in miliseconds + $resetCounter = 86400 # 1 day in seconds + $services = Get-CimInstance -ClassName 'Win32_Service' | Where-Object { $_.DisplayName -imatch $ServiceDisplayName } + $action = $action1 + "/" + $time1 + "/" + $action2 + "/" + $time2 + "/" + $actionLast + "/" + $timeLast + foreach ($service in $services) { + # https://technet.microsoft.com/en-us/library/cc742019.aspx + $output = sc.exe failure $($service.Name) actions= $action reset= $resetCounter + } + # set service to delayed auto-start (doesn't reflect in the services console until after a reboot) + Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\AtomicRunnerService" -Name Start -Value 2 + Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\AtomicRunnerService" -Name DelayedAutostart -Value 1 # remove scheduled task now that we are using a service instead Unregister-ScheduledTask "KickOff-AtomicRunner" -confirm:$false -ErrorAction Ignore From 6e4b0fc0f2372277677fae8d01bccb8bcec11edf Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Fri, 22 Mar 2024 15:41:25 -0500 Subject: [PATCH 04/19] add comments --- Public/AtomicRunnerService.ps1 | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Public/AtomicRunnerService.ps1 b/Public/AtomicRunnerService.ps1 index 3c46fab..3ec9ad4 100644 --- a/Public/AtomicRunnerService.ps1 +++ b/Public/AtomicRunnerService.ps1 @@ -1,10 +1,11 @@ +# Slightly modified the PSService example below to kick-off the atomic runner ############################################################################### # # -# File name AtomicRunnerService.ps1 # +# File name PSService.ps1 # # # # Description A sample service in a standalone PowerShell script # # # -# Notes The latest AtomicRunnerService.ps1 version is available in GitHub # +# Notes The latest PSService.ps1 version is available in GitHub # # repository https://github.com/JFLarvoire/SysToolsLib/ , # # in the PowerShell subdirectory. # # Please report any problem in the Issues tab in that # @@ -29,7 +30,7 @@ # reusable, and will allow building much more powerful # # services. # # # -# Dynamically generates a small AtomicRunnerService.exe wrapper # +# Dynamically generates a small PSService.exe wrapper # # application, that in turn invokes this PowerShell script. # # # # Some arguments are inspired by Linux' service management # @@ -50,14 +51,14 @@ # suitable for most projects. # # # # Service installation and usage: See the dynamic help # -# section below, or run: help .\AtomicRunnerService.ps1 -Detailed # +# section below, or run: help .\PSService.ps1 -Detailed # # # # Debugging: The Log function writes messages into a file # -# called C:\Windows\Logs\AtomicRunnerService.log (or actually # +# called C:\Windows\Logs\PSService.log (or actually # # ${env:windir}\Logs\$serviceName.log). # # It is very convenient to monitor what's written into that # # file with a WIN32 port of the Unix tail program. Usage: # -# tail -f C:\Windows\Logs\AtomicRunnerService.log # +# tail -f C:\Windows\Logs\PSService.log # # # # History # # 2015-07-10 JFL jf.larvoire@hpe.com created this script. # @@ -95,7 +96,7 @@ # script parameters. # # 2017-12-10 JFL Removed the unreliable service account detection tests, # # and instead use dedicated -SCMStart and -SCMStop # -# arguments in the AtomicRunnerService.exe helper app. # +# arguments in the PSService.exe helper app. # # Renamed variable userName as currentUserName. # # Renamed arguments ServiceUser and ServicePassword to the # # more standard UserName and Password. # From 03f8d846af2da90b68c3c8ece49a305e93032c1e Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Fri, 22 Mar 2024 18:47:30 -0500 Subject: [PATCH 05/19] remove old cred file reference --- Public/config.ps1 | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Public/config.ps1 b/Public/config.ps1 index f064fc6..dc911d9 100644 --- a/Public/config.ps1 +++ b/Public/config.ps1 @@ -83,14 +83,6 @@ $scriptParam = @{ } Add-Member @scriptParam -$scriptParam = @{ - MemberType = "ScriptProperty" - InputObject = $artConfig - Name = "credFile" - Value = { Join-Path $artConfig.runnerFolder "psc_$($artConfig.basehostname).txt" } -} -Add-Member @scriptParam - $scriptParam = @{ MemberType = "ScriptProperty" InputObject = $artConfig From 42d592c0260a0f99bafd518e7b297ffe695c0e8b Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Fri, 22 Mar 2024 19:28:34 -0500 Subject: [PATCH 06/19] remove old cred file reference --- Public/Invoke-AtomicRunner.ps1 | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Public/Invoke-AtomicRunner.ps1 b/Public/Invoke-AtomicRunner.ps1 index 8038f0c..bf54ef0 100755 --- a/Public/Invoke-AtomicRunner.ps1 +++ b/Public/Invoke-AtomicRunner.ps1 @@ -122,13 +122,11 @@ function Invoke-AtomicRunner { } } else { - $cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $artConfig.user, (Get-Content $artConfig.credFile | ConvertTo-SecureString) try { - Rename-Computer -NewName $newHostName -Force -DomainCredential $cred -Restart -ErrorAction stop + Rename-Computer -NewName $newHostName -Force -Restart -ErrorAction stop } catch { if ($artConfig.verbose) { LogRunnerMsg $_ } - try { Rename-Computer -NewName $newHostName -Force -LocalCredential $cred -Restart -ErrorAction stop } catch { if ($artConfig.verbose) { LogRunnerMsg $_ } } } } Start-Sleep -seconds 30 From be0f8076ef3e7930b6ffdc4dc69d4d26ab5b2780 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Fri, 22 Mar 2024 21:38:22 -0500 Subject: [PATCH 07/19] add logging mutex --- Public/Invoke-KickoffAtomicRunner.ps1 | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Public/Invoke-KickoffAtomicRunner.ps1 b/Public/Invoke-KickoffAtomicRunner.ps1 index a34c028..994f3f7 100644 --- a/Public/Invoke-KickoffAtomicRunner.ps1 +++ b/Public/Invoke-KickoffAtomicRunner.ps1 @@ -53,7 +53,17 @@ function Invoke-KickoffAtomicRunner { } function LogRunnerMsg ($message) { - $now = "[{0:MM/dd/yy} {0:HH:mm:ss}]" -f (Get-Date) - Write-Host -fore cyan $message - Add-Content $artConfig.logFile "$now`: $message" + $mutexName = 'AtomicRunnerLoggingMutex' + $mutex = New-Object 'Threading.Mutex' $false, $mutexName + # Grab the mutex. Will block until this process has it. + $mutex.WaitOne(); + try { + # OK. Now it is safe to write to your log file + $now = "[{0:MM/dd/yy} {0:HH:mm:ss}]" -f (Get-Date) + Write-Host -fore cyan $message + Add-Content $artConfig.logFile "$now`: $message" + } + finally { + $mutex.ReleaseMutex() + } } \ No newline at end of file From da6d4c98e2ede2b85633fa6798aafad019361e77 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Mon, 25 Mar 2024 18:15:29 -0500 Subject: [PATCH 08/19] kill runner process on service stop --- Invoke-AtomicRedTeam.psd1 | 3 ++- Public/AtomicRunnerService.ps1 | 4 +++- Public/Invoke-KickoffAtomicRunner.ps1 | 6 ++++-- {Private => Public}/Invoke-KillProcessTree.ps1 | 0 4 files changed, 9 insertions(+), 4 deletions(-) rename {Private => Public}/Invoke-KillProcessTree.ps1 (100%) diff --git a/Invoke-AtomicRedTeam.psd1 b/Invoke-AtomicRedTeam.psd1 index f97202b..3ee44a0 100644 --- a/Invoke-AtomicRedTeam.psd1 +++ b/Invoke-AtomicRedTeam.psd1 @@ -47,7 +47,8 @@ 'Invoke-AtomicRunner', 'Get-Schedule', 'Invoke-KickoffAtomicRunner', - 'Get-PreferredIPAddress' + 'Get-PreferredIPAddress', + 'Invoke-KillProcessTree' ) # Variables to export from this module diff --git a/Public/AtomicRunnerService.ps1 b/Public/AtomicRunnerService.ps1 index 3ec9ad4..1b1381f 100644 --- a/Public/AtomicRunnerService.ps1 +++ b/Public/AtomicRunnerService.ps1 @@ -1074,7 +1074,7 @@ if ($Service) { # Run the service ######### TO DO: Implement your own service code here. ########## . $profile - Invoke-KickoffAtomicRunner + $p1,$p2 = Invoke-KickoffAtomicRunner # Now enter the main service event loop do { # Keep running until told to exit by the -Stop handler $event = Wait-Event # Wait for the next incoming event @@ -1113,6 +1113,8 @@ if ($Service) { # Run the service $line = $_.InvocationInfo.ScriptLineNumber Log "$scriptName -Service # Error at line ${line}: $msg" } finally { # Invoked in all cases: Exception or normally by -Stop + Invoke-KillProcessTree $p1.Id -ErrorAction Ignore + Invoke-KillProcessTree $p2.Id -ErrorAction Ignore ############### End of the service code example. ################ # Terminate the control pipe handler thread diff --git a/Public/Invoke-KickoffAtomicRunner.ps1 b/Public/Invoke-KickoffAtomicRunner.ps1 index 994f3f7..9f3e21d 100644 --- a/Public/Invoke-KickoffAtomicRunner.ps1 +++ b/Public/Invoke-KickoffAtomicRunner.ps1 @@ -45,10 +45,12 @@ function Invoke-KickoffAtomicRunner { $FileName = if ($IsLinux -or $IsMacOS) { "pwsh" } else { "powershell.exe" } if ($artConfig.debug) { $Arguments = "-Command Invoke-AtomicRunner *>> $all_log_file" } else { $Arguments = "-Command Invoke-AtomicRunner" } # Invoke the atomic as its own process because we don't want to skip the cleanup and rename process in the event that AV kills the process running the atomic - Start-Process -FilePath $FileName -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory + $p1 = Start-Process -FilePath $FileName -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -PassThru if ($artConfig.debug) { $Arguments = "-Command Invoke-AtomicRunner -scheduledTaskCleanup *>> $all_log_file_cleanup" } else { $Arguments = "-Command Invoke-AtomicRunner -scheduledTaskCleanup" } - Start-Process -FilePath $FileName -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory + $p2 = Start-Process -FilePath $FileName -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -PassThru + + return $p1,$p2 } diff --git a/Private/Invoke-KillProcessTree.ps1 b/Public/Invoke-KillProcessTree.ps1 similarity index 100% rename from Private/Invoke-KillProcessTree.ps1 rename to Public/Invoke-KillProcessTree.ps1 From 095898bc1269729176c2f634a908a3850d44f5c6 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Mon, 25 Mar 2024 19:56:11 -0500 Subject: [PATCH 09/19] skip service setup option --- Public/Invoke-SetupAtomicRunner.ps1 | 57 +++++++++++++++++------------ 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/Public/Invoke-SetupAtomicRunner.ps1 b/Public/Invoke-SetupAtomicRunner.ps1 index c08a184..9bddad6 100755 --- a/Public/Invoke-SetupAtomicRunner.ps1 +++ b/Public/Invoke-SetupAtomicRunner.ps1 @@ -1,5 +1,15 @@ function Invoke-SetupAtomicRunner { + [CmdletBinding( + SupportsShouldProcess = $true, + PositionalBinding = $false, + ConfirmImpact = 'Medium')] + Param( + [Parameter(Mandatory = $false)] + [switch] + $SkipServiceSetup + ) + # ensure running with admin privs if ($artConfig.OS -eq "windows") { # auto-elevate on Windows @@ -25,30 +35,31 @@ function Invoke-SetupAtomicRunner { New-Item -ItemType Directory $artConfig.runnerFolder -ErrorAction Ignore if ($artConfig.OS -eq "windows") { - - # create the service that will start the runner after each restart - # The user must have the "Log on as a service" right. To add that right, open the Local Security Policy management console, go to the - # "\Security Settings\Local Policies\User Rights Assignments" folder, and edit the "Log on as a service" policy there. - . "$PSScriptRoot\AtomicRunnerService.ps1" -Remove - . "$PSScriptRoot\AtomicRunnerService.ps1" -UserName $artConfig.user -Setup - # set service start retry options - $ServiceDisplayName = "AtomicRunnerService" - $action1, $action2, $action3 = "restart" - $time1 = 600000 # 10 minutes in miliseconds - $action2 = "restart" - $time2 = 600000 # 10 minutes in miliseconds - $actionLast = "restart" - $timeLast = 3600000 # 1 hour in miliseconds - $resetCounter = 86400 # 1 day in seconds - $services = Get-CimInstance -ClassName 'Win32_Service' | Where-Object { $_.DisplayName -imatch $ServiceDisplayName } - $action = $action1 + "/" + $time1 + "/" + $action2 + "/" + $time2 + "/" + $actionLast + "/" + $timeLast - foreach ($service in $services) { - # https://technet.microsoft.com/en-us/library/cc742019.aspx - $output = sc.exe failure $($service.Name) actions= $action reset= $resetCounter + if (-not $SkipServiceSetup) { + # create the service that will start the runner after each restart + # The user must have the "Log on as a service" right. To add that right, open the Local Security Policy management console, go to the + # "\Security Settings\Local Policies\User Rights Assignments" folder, and edit the "Log on as a service" policy there. + . "$PSScriptRoot\AtomicRunnerService.ps1" -Remove + . "$PSScriptRoot\AtomicRunnerService.ps1" -UserName $artConfig.user -Setup + # set service start retry options + $ServiceDisplayName = "AtomicRunnerService" + $action1, $action2, $action3 = "restart" + $time1 = 600000 # 10 minutes in miliseconds + $action2 = "restart" + $time2 = 600000 # 10 minutes in miliseconds + $actionLast = "restart" + $timeLast = 3600000 # 1 hour in miliseconds + $resetCounter = 86400 # 1 day in seconds + $services = Get-CimInstance -ClassName 'Win32_Service' | Where-Object { $_.DisplayName -imatch $ServiceDisplayName } + $action = $action1 + "/" + $time1 + "/" + $action2 + "/" + $time2 + "/" + $actionLast + "/" + $timeLast + foreach ($service in $services) { + # https://technet.microsoft.com/en-us/library/cc742019.aspx + $output = sc.exe failure $($service.Name) actions= $action reset= $resetCounter + } + # set service to delayed auto-start (doesn't reflect in the services console until after a reboot) + Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\AtomicRunnerService" -Name Start -Value 2 + Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\AtomicRunnerService" -Name DelayedAutostart -Value 1 } - # set service to delayed auto-start (doesn't reflect in the services console until after a reboot) - Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\AtomicRunnerService" -Name Start -Value 2 - Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\AtomicRunnerService" -Name DelayedAutostart -Value 1 # remove scheduled task now that we are using a service instead Unregister-ScheduledTask "KickOff-AtomicRunner" -confirm:$false -ErrorAction Ignore From a360fdea27e81547b0e8baa18bbd3a0b79fd0e23 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Tue, 26 Mar 2024 10:14:24 -0500 Subject: [PATCH 10/19] serviceInstallDir option --- Public/AtomicRunnerService.ps1 | 5 +++-- Public/Invoke-SetupAtomicRunner.ps1 | 34 ++++++++++++++++++++++++++++- Public/config.ps1 | 3 +++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/Public/AtomicRunnerService.ps1 b/Public/AtomicRunnerService.ps1 index 1b1381f..094b243 100644 --- a/Public/AtomicRunnerService.ps1 +++ b/Public/AtomicRunnerService.ps1 @@ -240,6 +240,9 @@ Param( [Parameter(ParameterSetName='Setup', Mandatory=$false)] [String]$Password, # Use this password for the user + + [Parameter(ParameterSetName='Setup', Mandatory=$false)] + [String]$installDir= "${ENV:windir}\System32", # Where to install the service files [Parameter(ParameterSetName='Setup2', Mandatory=$false)] [System.Management.Automation.PSCredential]$Credential, # Service account credential @@ -275,8 +278,6 @@ $serviceName = $script # A one-word name used for net start com $serviceDisplayName = "AtomicRunnerService" $ServiceDescription = "Executes the Invoke-KickOffAtomicRunner Script" $pipeName = "Service_$serviceName" # Named pipe name. Used for sending messages to the service task -# $installDir = "${ENV:ProgramFiles}\$serviceName" # Where to install the service files -$installDir = "${ENV:windir}\System32" # Where to install the service files $scriptCopy = "$installDir\$scriptName" $exeName = "$serviceName.exe" $exeFullName = "$installDir\$exeName" diff --git a/Public/Invoke-SetupAtomicRunner.ps1 b/Public/Invoke-SetupAtomicRunner.ps1 index 9bddad6..18ccc57 100755 --- a/Public/Invoke-SetupAtomicRunner.ps1 +++ b/Public/Invoke-SetupAtomicRunner.ps1 @@ -40,7 +40,8 @@ function Invoke-SetupAtomicRunner { # The user must have the "Log on as a service" right. To add that right, open the Local Security Policy management console, go to the # "\Security Settings\Local Policies\User Rights Assignments" folder, and edit the "Log on as a service" policy there. . "$PSScriptRoot\AtomicRunnerService.ps1" -Remove - . "$PSScriptRoot\AtomicRunnerService.ps1" -UserName $artConfig.user -Setup + . "$PSScriptRoot\AtomicRunnerService.ps1" -UserName $artConfig.user -installDir $artConfig.serviceInstallDir -Setup + Add-EnvPath -Container Machine -Path $artConfig.serviceInstallDir # set service start retry options $ServiceDisplayName = "AtomicRunnerService" $action1, $action2, $action3 = "restart" @@ -114,3 +115,34 @@ function Invoke-SetupAtomicRunner { Invoke-AtomicRunner -GetPrereqs } } + +# Add-EnvPath from https://gist.github.com/mkropat/c1226e0cc2ca941b23a9 +function Add-EnvPath { + param( + [Parameter(Mandatory=$true)] + [string] $Path, + + [ValidateSet('Machine', 'User', 'Session')] + [string] $Container = 'Session' + ) + + if ($Container -ne 'Session') { + $containerMapping = @{ + Machine = [EnvironmentVariableTarget]::Machine + User = [EnvironmentVariableTarget]::User + } + $containerType = $containerMapping[$Container] + + $persistedPaths = [Environment]::GetEnvironmentVariable('Path', $containerType) -split ';' + if ($persistedPaths -notcontains $Path) { + $persistedPaths = $persistedPaths + $Path | where { $_ } + [Environment]::SetEnvironmentVariable('Path', $persistedPaths -join ';', $containerType) + } + } + + $envPaths = $env:Path -split ';' + if ($envPaths -notcontains $Path) { + $envPaths = $envPaths + $Path | where { $_ } + $env:Path = $envPaths -join ';' + } +} \ No newline at end of file diff --git a/Public/config.ps1 b/Public/config.ps1 index dc911d9..3740221 100644 --- a/Public/config.ps1 +++ b/Public/config.ps1 @@ -41,6 +41,9 @@ $artConfig = [PSCustomObject]@{ # amsi bypass script block (applies to Windows only) absb = $null + # AtomicRunnerService install directory + ServiceInstallDir = "${ENV:windir}\System32" + } # If you create a file called privateConfig.ps1 in the same directory as you installed Invoke-AtomicRedTeam you can overwrite any of these settings with your custom values From 8020d404e456e699105d4ca320d9f0d0ab1ea96f Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Tue, 26 Mar 2024 10:47:03 -0500 Subject: [PATCH 11/19] log process IDs --- Public/AtomicRunnerService.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/Public/AtomicRunnerService.ps1 b/Public/AtomicRunnerService.ps1 index 094b243..8d7e59a 100644 --- a/Public/AtomicRunnerService.ps1 +++ b/Public/AtomicRunnerService.ps1 @@ -1076,6 +1076,7 @@ if ($Service) { # Run the service ######### TO DO: Implement your own service code here. ########## . $profile $p1,$p2 = Invoke-KickoffAtomicRunner + Log "p1: $($p1.Id) p2: $($p2.Id)" # Now enter the main service event loop do { # Keep running until told to exit by the -Stop handler $event = Wait-Event # Wait for the next incoming event From 2166a09e47b6aba2b6e587b9d0161682909a4f52 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Tue, 26 Mar 2024 10:50:35 -0500 Subject: [PATCH 12/19] out-null --- Public/Invoke-KickoffAtomicRunner.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Public/Invoke-KickoffAtomicRunner.ps1 b/Public/Invoke-KickoffAtomicRunner.ps1 index 9f3e21d..c75a27a 100644 --- a/Public/Invoke-KickoffAtomicRunner.ps1 +++ b/Public/Invoke-KickoffAtomicRunner.ps1 @@ -58,7 +58,7 @@ function LogRunnerMsg ($message) { $mutexName = 'AtomicRunnerLoggingMutex' $mutex = New-Object 'Threading.Mutex' $false, $mutexName # Grab the mutex. Will block until this process has it. - $mutex.WaitOne(); + $mutex.WaitOne() | Out-Null try { # OK. Now it is safe to write to your log file $now = "[{0:MM/dd/yy} {0:HH:mm:ss}]" -f (Get-Date) From 17f6f2f6da14b9bcafae3ad9a839f81f470c94be Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Wed, 27 Mar 2024 20:15:13 -0500 Subject: [PATCH 13/19] runner showdetails supports anyOS flag --- Public/Invoke-AtomicRunner.ps1 | 19 ++++++++++++------- Public/Invoke-RunnerScheduleMethods.ps1 | 14 ++++++++++---- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Public/Invoke-AtomicRunner.ps1 b/Public/Invoke-AtomicRunner.ps1 index bf54ef0..0a35edb 100755 --- a/Public/Invoke-AtomicRunner.ps1 +++ b/Public/Invoke-AtomicRunner.ps1 @@ -33,6 +33,10 @@ function Invoke-AtomicRunner { [Parameter(Mandatory = $false)] $ListOfAtomics, + [Parameter(Mandatory = $false)] + [switch] + $anyOS=$false, + [parameter(Mandatory = $false)] [ValidateRange(0, [int]::MaxValue)] [int] $PauseBetweenAtomics, @@ -60,7 +64,7 @@ function Invoke-AtomicRunner { if ($guid -match $guidRegex) { return $guid } else { return "" } } - function Invoke-AtomicTestFromScheduleRow ($tr, $Cleanup = $false) { + function Invoke-AtomicTestFromScheduleRow ($tr) { $theArgs = $tr.InputArgs if ($theArgs.GetType().Name -ne "Hashtable") { $tr.InputArgs = ConvertFrom-StringData -StringData $theArgs @@ -68,10 +72,10 @@ function Invoke-AtomicRunner { $sc = $tr.AtomicsFolder #Run the Test based on if scheduleContext is 'private' or 'public' if (($sc -eq 'public') -or ($null -eq $sc)) { - Invoke-AtomicTest $tr.Technique -TestGuids $tr.auto_generated_guid -InputArgs $tr.InputArgs -TimeoutSeconds $tr.TimeoutSeconds -ExecutionLogPath $artConfig.execLogPath -PathToAtomicsFolder $artConfig.PathToPublicAtomicsFolder @htvars -Cleanup:$Cleanup -supressPathToAtomicsFolder + Invoke-AtomicTest $tr.Technique -TestGuids $tr.auto_generated_guid -InputArgs $tr.InputArgs -TimeoutSeconds $tr.TimeoutSeconds -ExecutionLogPath $artConfig.execLogPath -PathToAtomicsFolder $artConfig.PathToPublicAtomicsFolder @htvars -supressPathToAtomicsFolder } elseif ($sc -eq 'private') { - Invoke-AtomicTest $tr.Technique -TestGuids $tr.auto_generated_guid -InputArgs $tr.InputArgs -TimeoutSeconds $tr.TimeoutSeconds -ExecutionLogPath $artConfig.execLogPath -PathToAtomicsFolder $artConfig.PathToPrivateAtomicsFolder @htvars -Cleanup:$Cleanup -supressPathToAtomicsFolder + Invoke-AtomicTest $tr.Technique -TestGuids $tr.auto_generated_guid -InputArgs $tr.InputArgs -TimeoutSeconds $tr.TimeoutSeconds -ExecutionLogPath $artConfig.execLogPath -PathToAtomicsFolder $artConfig.PathToPrivateAtomicsFolder @htvars -supressPathToAtomicsFolder } if ($timeToPause -gt 0) { Write-Host "Sleeping for $timeToPause seconds..." @@ -177,11 +181,11 @@ function Invoke-AtomicRunner { $htvars += [Hashtable]$PSBoundParameters $htvars.Remove('listOfAtomics') | Out-Null $htvars.Remove('OtherArgs') | Out-Null - $htvars.Remove('Cleanup') | Out-Null $htvars.Remove('PauseBetweenAtomics') | Out-Null $htvars.Remove('scheduledTaskCleanup') | Out-Null - $schedule = Get-Schedule $listOfAtomics + $schedule = Get-Schedule $listOfAtomics $true $null (-not $anyOS) + # If the schedule is empty, end process if (-not $schedule) { LogRunnerMsg "No test guid's or enabled tests." @@ -194,7 +198,7 @@ function Invoke-AtomicRunner { # Perform cleanup, Showdetails or Prereq stuff for all scheduled items and then exit if ($Cleanup -or $ShowDetails -or $CheckPrereqs -or $ShowDetailsBrief -or $GetPrereqs -or $listOfAtomics) { $schedule | ForEach-Object { - Invoke-AtomicTestFromScheduleRow $_ $Cleanup + Invoke-AtomicTestFromScheduleRow $_ } return } @@ -223,7 +227,8 @@ function Invoke-AtomicRunner { if ($scheduledTaskCleanup) { # Cleanup after running test Write-Host -Fore cyan "Sleeping for $SleepTillCleanup seconds before cleaning up for $($tr.Technique) $($tr.auto_generated_guid) "; Start-Sleep -Seconds $SleepTillCleanup - Invoke-AtomicTestFromScheduleRow $tr $true + $htvars.Add("Cleanup") + Invoke-AtomicTestFromScheduleRow $tr } else { # run the atomic test and exit diff --git a/Public/Invoke-RunnerScheduleMethods.ps1 b/Public/Invoke-RunnerScheduleMethods.ps1 index 6b19b06..7174fbc 100644 --- a/Public/Invoke-RunnerScheduleMethods.ps1 +++ b/Public/Invoke-RunnerScheduleMethods.ps1 @@ -96,7 +96,7 @@ function Get-ScheduleRefresh() { } -function Get-Schedule($listOfAtomics, $filtered = $true, $testGuids = $null) { +function Get-Schedule($listOfAtomics, $filterByEnabled = $true, $testGuids = $null, $filterByPlatform = $true) { if ($listOfAtomics -or (Test-Path($artConfig.scheduleFile))) { if ($listOfAtomics) { $schedule = Import-Csv $listOfAtomics @@ -111,9 +111,15 @@ function Get-Schedule($listOfAtomics, $filtered = $true, $testGuids = $null) { ($Null -ne $TestGuids -and $TestGuids -contains $_.auto_generated_guid) } } - elseif ($filtered) { - $schedule = $schedule | Where-Object { - ($_.enabled -eq $true -and ($_.supported_platforms -like "*" + $artConfig.OS + "*" )) + else { + if ($filterByEnabled -and $filterByPlatform) { + $schedule = $schedule | Where-Object { ($_.enabled -eq $true -and ($_.supported_platforms -like "*" + $artConfig.OS + "*" )) } + } + elseif ($filterByEnabled) { + $schedule = $schedule | Where-Object { $_.enabled -eq $true } + } + elseif ($filterByPlatform) { + $schedule = $schedule | Where-Object { $_.supported_platforms -like "*" + $artConfig.OS + "*" } } } From e25f2e0d28b8fed07eb5552fbfa3c607e9ec2b39 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Wed, 27 Mar 2024 21:02:03 -0500 Subject: [PATCH 14/19] merge from master --- Public/Invoke-AtomicRunner.ps1 | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Public/Invoke-AtomicRunner.ps1 b/Public/Invoke-AtomicRunner.ps1 index c51bc14..076bd23 100755 --- a/Public/Invoke-AtomicRunner.ps1 +++ b/Public/Invoke-AtomicRunner.ps1 @@ -227,12 +227,8 @@ function Invoke-AtomicRunner { if ($scheduledTaskCleanup) { # Cleanup after running test Write-Host -Fore cyan "Sleeping for $SleepTillCleanup seconds before cleaning up for $($tr.Technique) $($tr.auto_generated_guid) "; Start-Sleep -Seconds $SleepTillCleanup -<<<<<<< HEAD $htvars.Add("Cleanup") Invoke-AtomicTestFromScheduleRow $tr -======= - Invoke-AtomicTestFromScheduleRow $tr $true ->>>>>>> f43d87e7447bcc8b180d7f0898d4a92fba4da33f } else { # run the atomic test and exit From f741b47fb63b292bef21bb06196de1b9e4918224 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Wed, 27 Mar 2024 21:30:53 -0500 Subject: [PATCH 15/19] create profile folder & file if needed --- Public/Invoke-SetupAtomicRunner.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Public/Invoke-SetupAtomicRunner.ps1 b/Public/Invoke-SetupAtomicRunner.ps1 index 3b284c3..8cf7cbf 100755 --- a/Public/Invoke-SetupAtomicRunner.ps1 +++ b/Public/Invoke-SetupAtomicRunner.ps1 @@ -84,6 +84,8 @@ function Invoke-SetupAtomicRunner { $root = Split-Path $PSScriptRoot -Parent $pathToPSD1 = Join-Path $root "Invoke-AtomicRedTeam.psd1" $importStatement = "Import-Module ""$pathToPSD1"" -Force" + $profileFolder = Split-Path $profile + New-Item -ItemType Directory -Force -Path $profileFolder | Out-Null New-Item $PROFILE -ErrorAction Ignore $profileContent = Get-Content $profile $line = $profileContent | Select-String ".*import-module.*invoke-atomicredTeam.psd1" | Select-Object -ExpandProperty Line From 1e70f7b19ec6cd775815f746595d3fb7deb15f3f Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Mon, 1 Apr 2024 13:04:03 -0500 Subject: [PATCH 16/19] add cleanup flag --- Public/Invoke-AtomicRunner.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Public/Invoke-AtomicRunner.ps1 b/Public/Invoke-AtomicRunner.ps1 index 076bd23..bb8f8d7 100755 --- a/Public/Invoke-AtomicRunner.ps1 +++ b/Public/Invoke-AtomicRunner.ps1 @@ -227,7 +227,7 @@ function Invoke-AtomicRunner { if ($scheduledTaskCleanup) { # Cleanup after running test Write-Host -Fore cyan "Sleeping for $SleepTillCleanup seconds before cleaning up for $($tr.Technique) $($tr.auto_generated_guid) "; Start-Sleep -Seconds $SleepTillCleanup - $htvars.Add("Cleanup") + $htvars.Add("Cleanup",$true) Invoke-AtomicTestFromScheduleRow $tr } else { From 2c6fb6972fb8f9098c7a4052abecb009013e7675 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Mon, 1 Apr 2024 19:55:09 -0500 Subject: [PATCH 17/19] computer rename retries --- Public/Invoke-AtomicRunner.ps1 | 33 ++++++++++++--------------------- Public/config.ps1 | 3 --- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/Public/Invoke-AtomicRunner.ps1 b/Public/Invoke-AtomicRunner.ps1 index bb8f8d7..3bac585 100755 --- a/Public/Invoke-AtomicRunner.ps1 +++ b/Public/Invoke-AtomicRunner.ps1 @@ -35,7 +35,7 @@ function Invoke-AtomicRunner { [Parameter(Mandatory = $false)] [switch] - $anyOS=$false, + $anyOS = $false, [parameter(Mandatory = $false)] [ValidateRange(0, [int]::MaxValue)] @@ -115,31 +115,22 @@ function Invoke-AtomicRunner { else { if ($debug) { LogRunnerMsg "Debug: pretending to rename the computer to $newHostName"; exit } if (-not $shouldRename) { Restart-Computer -Force } - if ($artConfig.gmsaAccount) { - $retry = $true; $count = 0 - while ($retry) { - # add retry loop to avoid this occassional error "The verification of the MSA failed with error 1355" - Invoke-Command -ComputerName '127.0.0.1' -ConfigurationName 'RenameRunnerEndpoint' -ScriptBlock { Rename-Computer -NewName $Using:newHostName -Force -Restart } - Start-Sleep 120; $count = $count + 1 - LogRunnerMsg "Retrying computer rename $count" - if ($count -gt 15) { $retry = $false } - } - } - else { - try { - Rename-Computer -NewName $newHostName -Force -Restart -ErrorAction stop - } - catch { - if ($artConfig.verbose) { LogRunnerMsg $_ } - } + $retry = $true; $count = 0 + while ($retry) { + Rename-Computer -NewName $newHostName -Force -Restart + Start-Sleep 120; $count = $count + 1 + LogRunnerMsg "Retrying computer rename $count" + if ($count -gt 60) { $retry = $false } } + Start-Sleep -seconds 30 LogRunnerMsg "uh oh, still haven't restarted - should never get to here" $retry = $true; $count = 0 while ($retry) { - Restart-Computer -Force - Start-Sleep 300; $count = $count + 1 + $count = $count + 1 LogRunnerMsg "Rename retry $count" + Restart-Computer -Force + Start-Sleep 300; if ($count -gt 60) { $retry = $false } } exit @@ -227,7 +218,7 @@ function Invoke-AtomicRunner { if ($scheduledTaskCleanup) { # Cleanup after running test Write-Host -Fore cyan "Sleeping for $SleepTillCleanup seconds before cleaning up for $($tr.Technique) $($tr.auto_generated_guid) "; Start-Sleep -Seconds $SleepTillCleanup - $htvars.Add("Cleanup",$true) + $htvars.Add("Cleanup", $true) Invoke-AtomicTestFromScheduleRow $tr } else { diff --git a/Public/config.ps1 b/Public/config.ps1 index 23171e8..662116d 100644 --- a/Public/config.ps1 +++ b/Public/config.ps1 @@ -21,9 +21,6 @@ $artConfig = [PSCustomObject]@{ kickOffDelay = New-TimeSpan -Minutes 0 # an additional delay before Invoke-KickoffAtomicRunner calls Invoke-AtomicRunner scheduleFileName = "AtomicRunnerSchedule.csv" - # [optional] If you need to use a group managed service account in order to rename the computer, enter it here - gmsaAccount = $null - # [optional] Logging Module, uses Syslog-ExecutionLogger if left blank and the syslogServer and syslogPort are set, otherwise it uses the Default-ExecutionLogger LoggingModule = '' From 2a6b59c0f9c8bcf5690ad370a845fddd145f85a1 Mon Sep 17 00:00:00 2001 From: Hare Sudhan Date: Sun, 14 Apr 2024 17:38:11 -0400 Subject: [PATCH 18/19] fix linting issues --- Public/AtomicRunnerService.ps1 | 20 ++++++++++---------- Public/Invoke-AtomicRunner.ps1 | 2 +- Public/Invoke-KickoffAtomicRunner.ps1 | 2 +- Public/Invoke-SetupAtomicRunner.ps1 | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Public/AtomicRunnerService.ps1 b/Public/AtomicRunnerService.ps1 index 8d7e59a..9530d2a 100644 --- a/Public/AtomicRunnerService.ps1 +++ b/Public/AtomicRunnerService.ps1 @@ -237,13 +237,13 @@ Param( [Parameter(ParameterSetName='Setup', Mandatory=$true)] [String]$UserName, # Set the service to run as this user - + [Parameter(ParameterSetName='Setup', Mandatory=$false)] - [String]$Password, # Use this password for the user + [SecureString]$Password, # Use this password for the user [Parameter(ParameterSetName='Setup', Mandatory=$false)] [String]$installDir= "${ENV:windir}\System32", # Where to install the service files - + [Parameter(ParameterSetName='Setup2', Mandatory=$false)] [System.Management.Automation.PSCredential]$Credential, # Service account credential @@ -331,7 +331,7 @@ Function Now { $ms = $true $nsSuffix = "000" } - } + } if ($ms) { $now += ".{0:000}$nsSuffix" -f $Date.MilliSecond } @@ -399,7 +399,7 @@ Function Get-PSThread () { [Parameter(Mandatory=$false, ValueFromPipeline=$true, Position=0)] [int[]]$Id = $PSThreadList.Keys # List of thread IDs ) - $Id | % { $PSThreadList.$_ } + $Id | ForEach-Object { $PSThreadList.$_ } } Function Start-PSThread () { @@ -441,7 +441,7 @@ Function Start-PSThread () { $PSPipeline = [powershell]::Create() $PSPipeline.Runspace = $RunSpace $PSPipeline.AddScript($ScriptBlock) | Out-Null - $Arguments | % { + $Arguments | ForEach-Object { Write-Debug "Adding argument [$($_.GetType())]'$_'" $PSPipeline.AddArgument($_) | Out-Null } @@ -764,7 +764,7 @@ $source = @" AutoLog = true; eventLog = new System.Diagnostics.EventLog(); // EVENT LOG [ - if (!System.Diagnostics.EventLog.SourceExists(ServiceName)) { + if (!System.Diagnostics.EventLog.SourceExists(ServiceName)) { System.Diagnostics.EventLog.CreateEventSource(ServiceName, "$logName"); } eventLog.Source = ServiceName; @@ -807,7 +807,7 @@ $source = @" Win32Exception w32ex = e as Win32Exception; // Try getting the WIN32 error code if (w32ex == null) { // Not a Win32 exception, but maybe the inner one is... w32ex = e.InnerException as Win32Exception; - } + } if (w32ex != null) { // Report the actual WIN32 error serviceStatus.dwWin32ExitCode = w32ex.NativeErrorCode; } else { // Make up a reasonable reason @@ -845,7 +845,7 @@ $source = @" Win32Exception w32ex = e as Win32Exception; // Try getting the WIN32 error code if (w32ex == null) { // Not a Win32 exception, but maybe the inner one is... w32ex = e.InnerException as Win32Exception; - } + } if (w32ex != null) { // Report the actual WIN32 error serviceStatus.dwWin32ExitCode = w32ex.NativeErrorCode; } else { // Make up a reasonable reason @@ -977,7 +977,7 @@ if ($Setup) { # Install the service Write-Debug "Installation is necessary" # Also avoids a ScriptAnalyzer warning # And continue with the installation. } - if (!(Test-Path $installDir)) { + if (!(Test-Path $installDir)) { New-Item -ItemType directory -Path $installDir | Out-Null } # Copy the service script into the installation directory diff --git a/Public/Invoke-AtomicRunner.ps1 b/Public/Invoke-AtomicRunner.ps1 index 3bac585..4324491 100755 --- a/Public/Invoke-AtomicRunner.ps1 +++ b/Public/Invoke-AtomicRunner.ps1 @@ -189,7 +189,7 @@ function Invoke-AtomicRunner { # Perform cleanup, Showdetails or Prereq stuff for all scheduled items and then exit if ($Cleanup -or $ShowDetails -or $CheckPrereqs -or $ShowDetailsBrief -or $GetPrereqs -or $listOfAtomics) { $schedule | ForEach-Object { - Invoke-AtomicTestFromScheduleRow $_ + Invoke-AtomicTestFromScheduleRow $_ } return } diff --git a/Public/Invoke-KickoffAtomicRunner.ps1 b/Public/Invoke-KickoffAtomicRunner.ps1 index 94bb0ef..7f79e07 100644 --- a/Public/Invoke-KickoffAtomicRunner.ps1 +++ b/Public/Invoke-KickoffAtomicRunner.ps1 @@ -46,7 +46,7 @@ function Invoke-KickoffAtomicRunner { if ($artConfig.debug) { $Arguments = "-Command Invoke-AtomicRunner *>> $all_log_file" } else { $Arguments = "-Command Invoke-AtomicRunner" } # Invoke the atomic as its own process because we don't want to skip the cleanup and rename process in the event that AV kills the process running the atomic $p1 = Start-Process -FilePath $FileName -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -PassThru - + if ($artConfig.debug) { $Arguments = "-Command Invoke-AtomicRunner -scheduledTaskCleanup *>> $all_log_file_cleanup" } else { $Arguments = "-Command Invoke-AtomicRunner -scheduledTaskCleanup" } $p2 = Start-Process -FilePath $FileName -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -PassThru diff --git a/Public/Invoke-SetupAtomicRunner.ps1 b/Public/Invoke-SetupAtomicRunner.ps1 index 8cf7cbf..9eac4ae 100755 --- a/Public/Invoke-SetupAtomicRunner.ps1 +++ b/Public/Invoke-SetupAtomicRunner.ps1 @@ -7,7 +7,7 @@ function Invoke-SetupAtomicRunner { Param( [Parameter(Mandatory = $false)] [switch] - $SkipServiceSetup + $SkipServiceSetup ) # ensure running with admin privs @@ -50,7 +50,7 @@ function Invoke-SetupAtomicRunner { $time2 = 600000 # 10 minutes in miliseconds $actionLast = "restart" $timeLast = 3600000 # 1 hour in miliseconds - $resetCounter = 86400 # 1 day in seconds + $resetCounter = 86400 # 1 day in seconds $services = Get-CimInstance -ClassName 'Win32_Service' | Where-Object { $_.DisplayName -imatch $ServiceDisplayName } $action = $action1 + "/" + $time1 + "/" + $action2 + "/" + $time2 + "/" + $actionLast + "/" + $timeLast foreach ($service in $services) { @@ -137,14 +137,14 @@ function Add-EnvPath { $persistedPaths = [Environment]::GetEnvironmentVariable('Path', $containerType) -split ';' if ($persistedPaths -notcontains $Path) { - $persistedPaths = $persistedPaths + $Path | where { $_ } + $persistedPaths = $persistedPaths + $Path | Where-Object { $_ } [Environment]::SetEnvironmentVariable('Path', $persistedPaths -join ';', $containerType) } } $envPaths = $env:Path -split ';' if ($envPaths -notcontains $Path) { - $envPaths = $envPaths + $Path | where { $_ } + $envPaths = $envPaths + $Path | Where-Object { $_ } $env:Path = $envPaths -join ';' } } \ No newline at end of file From 0667f355aaac04dc3825e1238a02122bf02a56f9 Mon Sep 17 00:00:00 2001 From: clr2of8 Date: Wed, 19 Jun 2024 13:01:57 -0500 Subject: [PATCH 19/19] add scheduled task option back in --- Public/Invoke-SetupAtomicRunner.ps1 | 61 +++++++++++++++++++++++++---- Public/config.ps1 | 8 ++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/Public/Invoke-SetupAtomicRunner.ps1 b/Public/Invoke-SetupAtomicRunner.ps1 index 8cf7cbf..3d4f440 100755 --- a/Public/Invoke-SetupAtomicRunner.ps1 +++ b/Public/Invoke-SetupAtomicRunner.ps1 @@ -7,7 +7,11 @@ function Invoke-SetupAtomicRunner { Param( [Parameter(Mandatory = $false)] [switch] - $SkipServiceSetup + $SkipServiceSetup, + + [Parameter(Mandatory = $false)] + [switch] + $asScheduledtask ) # ensure running with admin privs @@ -35,7 +39,50 @@ function Invoke-SetupAtomicRunner { New-Item -ItemType Directory $artConfig.runnerFolder -ErrorAction Ignore if ($artConfig.OS -eq "windows") { - if (-not $SkipServiceSetup) { + if ($asScheduledtask) { + if (Test-Path $artConfig.credFile) { + Write-Host "Credential File $($artConfig.credFile) already exists, not prompting for creation of a new one." + $cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $artConfig.user, (Get-Content $artConfig.credFile | ConvertTo-SecureString) + } + else { + # create credential file for the user since we aren't using a group managed service account + $cred = Get-Credential -UserName $artConfig.user -message "Enter password for $($artConfig.user) in order to create the runner scheduled task" + $cred.Password | ConvertFrom-SecureString | Out-File $artConfig.credFile + } + # setup scheduled task that will start the runner after each restart + # local security policy --> Local Policies --> Security Options --> Network access: Do not allow storage of passwords and credentials for network authentication must be disabled + $taskName = "KickOff-AtomicRunner" + Unregister-ScheduledTask $taskName -confirm:$false -ErrorAction Ignore + # Windows scheduled task includes a 20 minutes sleep then restart if the call to Invoke-KickoffAtomicRunner fails + # this occurs occassionally when Windows has issues logging into the runner user's account and logs in as a TEMP user + $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-exec bypass -Command Invoke-KickoffAtomicRunner; Start-Sleep 1200; Restart-Computer -Force" + $taskPrincipal = New-ScheduledTaskPrincipal -UserId $artConfig.user + $delays = @(1, 2, 4, 8, 16, 32, 64) # using multiple triggers as a retry mechanism because the built-in retry mechanism doesn't work when the computer renaming causes AD replication delays + $triggers = @() + foreach ($delay in $delays) { + $trigger = New-ScheduledTaskTrigger -AtStartup + $trigger.Delay = "PT$delay`M" + $triggers += $trigger + } + $task = New-ScheduledTask -Action $taskAction -Principal $taskPrincipal -Trigger $triggers -Description "A task that runs 1 minute or later after boot to start the atomic test runner script" + try { + $null = Register-ScheduledTask -TaskName $taskName -InputObject $task -User $artConfig.user -Password $($cred.GetNetworkCredential().password) -ErrorAction Stop + } + catch { + if ($_.CategoryInfo.Category -eq "AuthenticationError") { + # remove the credential file if the password didn't work + Write-Error "The credentials you entered are incorrect. Please run the setup script again and double check the username and password." + Remove-Item $artConfig.credFile + } + else { + Throw $_ + } + } + + # remove the atomicrunnerservice now that we are using a scheduled task instead + . "$PSScriptRoot\AtomicRunnerService.ps1" -Remove + } + elseif (-not $SkipServiceSetup) { # create the service that will start the runner after each restart # The user must have the "Log on as a service" right. To add that right, open the Local Security Policy management console, go to the # "\Security Settings\Local Policies\User Rights Assignments" folder, and edit the "Log on as a service" policy there. @@ -60,10 +107,10 @@ function Invoke-SetupAtomicRunner { # set service to delayed auto-start (doesn't reflect in the services console until after a reboot) Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\AtomicRunnerService" -Name Start -Value 2 Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\AtomicRunnerService" -Name DelayedAutostart -Value 1 - } - # remove scheduled task now that we are using a service instead - Unregister-ScheduledTask "KickOff-AtomicRunner" -confirm:$false -ErrorAction Ignore + # remove scheduled task now that we are using a service instead + Unregister-ScheduledTask "KickOff-AtomicRunner" -confirm:$false -ErrorAction Ignore + } } else { # sets cronjob string using basepath from config.ps1 @@ -121,7 +168,7 @@ function Invoke-SetupAtomicRunner { # Add-EnvPath from https://gist.github.com/mkropat/c1226e0cc2ca941b23a9 function Add-EnvPath { param( - [Parameter(Mandatory=$true)] + [Parameter(Mandatory = $true)] [string] $Path, [ValidateSet('Machine', 'User', 'Session')] @@ -131,7 +178,7 @@ function Add-EnvPath { if ($Container -ne 'Session') { $containerMapping = @{ Machine = [EnvironmentVariableTarget]::Machine - User = [EnvironmentVariableTarget]::User + User = [EnvironmentVariableTarget]::User } $containerType = $containerMapping[$Container] diff --git a/Public/config.ps1 b/Public/config.ps1 index 662116d..2a92005 100644 --- a/Public/config.ps1 +++ b/Public/config.ps1 @@ -83,6 +83,14 @@ $scriptParam = @{ } Add-Member @scriptParam +$scriptParam = @{ + MemberType = "ScriptProperty" + InputObject = $artConfig + Name = "credFile" + Value = { Join-Path $artConfig.runnerFolder "psc_$($artConfig.basehostname).txt" } +} +Add-Member @scriptParam + $scriptParam = @{ MemberType = "ScriptProperty" InputObject = $artConfig