Private/Service-Main.ps1
|
#Requires -Version 5.1 #Requires -RunAsAdministrator <# .SYNOPSIS MakeMeAdminCLI Named Pipe Server - Main service script. .DESCRIPTION This script implements a named pipe server that listens for requests from non-elevated users to grant temporary administrator rights. It runs as SYSTEM (either as a Windows service or a scheduled task) and handles: - Adding users to the local Administrators group - Creating scheduled tasks to remove users after the configured timeout - Processing status queries about active elevated users - Validating that requesters match the authenticated pipe client .NOTES Author: MakeMeAdminCLI Version: 1.0.0 This script should be run as SYSTEM via a Windows service or scheduled task. Named Pipe Protocol: - Pipe Name: MakeMeAdminCLI (configurable) - Request Format: JSON { "action": "add|remove|status", "username": "DOMAIN\\user", "duration": 15 } - Response Format: JSON { "success": true|false, "message": "...", "expiresAt": "ISO8601 datetime" } .PARAMETER RunOnce If specified, processes a single request and exits instead of running continuously. .PARAMETER Timeout The timeout in seconds for each pipe connection. Defaults to 30 seconds. .EXAMPLE # Run as a continuous service .\Service-Main.ps1 .EXAMPLE # Run once for testing .\Service-Main.ps1 -RunOnce #> [CmdletBinding()] param( [switch]$RunOnce, [int]$Timeout = 30 ) # Relaunch in 64-bit PowerShell if currently in 32-bit if (-not [Environment]::Is64BitProcess) { $ps64 = "$env:WINDIR\SysNative\WindowsPowerShell\v1.0\powershell.exe" if (Test-Path $ps64) { $arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" if ($RunOnce) { $arguments += " -RunOnce" } $arguments += " -Timeout $Timeout" & $ps64 $arguments.Split(' ') exit $LASTEXITCODE } } $ErrorActionPreference = 'Stop' # Get script directory and load dependencies $ScriptRoot = Split-Path -Parent $PSCommandPath $ModuleRoot = Split-Path -Parent $ScriptRoot # Dot-source the helper function files . (Join-Path $ScriptRoot "Config-Functions.ps1") . (Join-Path $ScriptRoot "Logging-Functions.ps1") . (Join-Path $ScriptRoot "AdminGroup-Functions.ps1") . (Join-Path $ScriptRoot "ScheduledTask-Functions.ps1") # Global state $script:Running = $true $script:ActiveServer = $null #region State Management function Initialize-StateFile { <# .SYNOPSIS Initializes or loads the state file for tracking active elevated users. #> [CmdletBinding()] param() $stateFilePath = Get-StateFilePath $stateFolder = Split-Path -Parent $stateFilePath # Ensure folder exists if (-not (Test-Path $stateFolder)) { New-Item -ItemType Directory -Path $stateFolder -Force | Out-Null } if (Test-Path $stateFilePath) { try { $state = Get-Content -Path $stateFilePath -Raw | ConvertFrom-Json # Validate structure if (-not $state.ActiveUsers) { $state | Add-Member -NotePropertyName "ActiveUsers" -NotePropertyValue @() -Force } return $state } catch { Write-Warning "Corrupted state file. Creating new one." } } # Create new state $state = [PSCustomObject]@{ ActiveUsers = @() LastUpdated = (Get-Date).ToString('o') ServiceStartTime = (Get-Date).ToString('o') } $state | ConvertTo-Json -Depth 10 | Set-Content -Path $stateFilePath -Encoding UTF8 -Force return $state } function Save-State { <# .SYNOPSIS Saves the current state to the state file. #> [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject]$State ) $stateFilePath = Get-StateFilePath $State.LastUpdated = (Get-Date).ToString('o') $State | ConvertTo-Json -Depth 10 | Set-Content -Path $stateFilePath -Encoding UTF8 -Force } function Add-ActiveUser { <# .SYNOPSIS Adds a user to the active elevated users list. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Username, [Parameter(Mandatory)] [datetime]$ExpiresAt, [string]$TaskName ) $state = Initialize-StateFile # Remove any existing entry for this user $state.ActiveUsers = @($state.ActiveUsers | Where-Object { $_.Username -ne $Username }) # Add new entry $userEntry = [PSCustomObject]@{ Username = $Username GrantedAt = (Get-Date).ToString('o') ExpiresAt = $ExpiresAt.ToString('o') TaskName = $TaskName } $state.ActiveUsers = @($state.ActiveUsers) + $userEntry Save-State -State $state } function Remove-ActiveUser { <# .SYNOPSIS Removes a user from the active elevated users list. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Username ) $state = Initialize-StateFile $state.ActiveUsers = @($state.ActiveUsers | Where-Object { $_.Username -ne $Username }) Save-State -State $state } function Get-ActiveUsers { <# .SYNOPSIS Gets the list of currently elevated users. #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param() $state = Initialize-StateFile return @($state.ActiveUsers) } #endregion #region Named Pipe Server function New-NamedPipeServer { <# .SYNOPSIS Creates a new named pipe server instance. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$PipeName ) try { # Create pipe security that allows authenticated users to connect $pipeSecurity = New-Object System.IO.Pipes.PipeSecurity # Allow SYSTEM full control $systemSid = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::LocalSystemSid, $null) $systemRule = New-Object System.IO.Pipes.PipeAccessRule($systemSid, [System.IO.Pipes.PipeAccessRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Allow) $pipeSecurity.AddAccessRule($systemRule) # Allow Administrators full control $adminSid = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, $null) $adminRule = New-Object System.IO.Pipes.PipeAccessRule($adminSid, [System.IO.Pipes.PipeAccessRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Allow) $pipeSecurity.AddAccessRule($adminRule) # Allow authenticated users to read/write (they need to connect to make requests) $authUsersSid = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::AuthenticatedUserSid, $null) $authUsersRule = New-Object System.IO.Pipes.PipeAccessRule($authUsersSid, [System.IO.Pipes.PipeAccessRights]::ReadWrite, [System.Security.AccessControl.AccessControlType]::Allow) $pipeSecurity.AddAccessRule($authUsersRule) # Create the named pipe server $pipeServer = New-Object System.IO.Pipes.NamedPipeServerStream( $PipeName, [System.IO.Pipes.PipeDirection]::InOut, 1, # Max instances [System.IO.Pipes.PipeTransmissionMode]::Message, [System.IO.Pipes.PipeOptions]::Asynchronous, 4096, # In buffer size 4096, # Out buffer size $pipeSecurity ) return $pipeServer } catch { Write-Error "Failed to create named pipe server: $($_.Exception.Message)" return $null } } function Get-PipeClientIdentity { <# .SYNOPSIS Gets the identity of the connected pipe client. #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.IO.Pipes.NamedPipeServerStream]$PipeServer ) try { # Get the client's Windows identity $pipeServer.RunAsClient({ [System.Security.Principal.WindowsIdentity]::GetCurrent().Name }) } catch { Write-Warning "Could not get pipe client identity: $($_.Exception.Message)" return $null } } function Read-PipeMessage { <# .SYNOPSIS Reads a JSON message from the named pipe. #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.IO.Pipes.NamedPipeServerStream]$PipeServer, [int]$TimeoutSeconds = 30 ) try { $reader = New-Object System.IO.StreamReader($PipeServer) # Read with timeout $readTask = $reader.ReadLineAsync() $completed = $readTask.Wait([TimeSpan]::FromSeconds($TimeoutSeconds)) if (-not $completed) { Write-Warning "Pipe read timeout" return $null } $message = $readTask.Result if ([string]::IsNullOrWhiteSpace($message)) { return $null } # Parse JSON return $message | ConvertFrom-Json } catch { Write-Warning "Error reading pipe message: $($_.Exception.Message)" return $null } } function Write-PipeResponse { <# .SYNOPSIS Writes a JSON response to the named pipe. #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.IO.Pipes.NamedPipeServerStream]$PipeServer, [Parameter(Mandatory)] [PSCustomObject]$Response ) try { $writer = New-Object System.IO.StreamWriter($PipeServer) $writer.AutoFlush = $true $json = $Response | ConvertTo-Json -Compress $writer.WriteLine($json) } catch { Write-Warning "Error writing pipe response: $($_.Exception.Message)" } } function New-Response { <# .SYNOPSIS Creates a standard response object. #> [CmdletBinding()] param( [bool]$Success, [string]$Message, [datetime]$ExpiresAt = [datetime]::MinValue, [PSCustomObject[]]$ActiveUsers = @() ) $response = [PSCustomObject]@{ success = $Success message = $Message } if ($ExpiresAt -ne [datetime]::MinValue) { $response | Add-Member -NotePropertyName "expiresAt" -NotePropertyValue $ExpiresAt.ToString('o') } if ($ActiveUsers.Count -gt 0) { $response | Add-Member -NotePropertyName "activeUsers" -NotePropertyValue $ActiveUsers } return $response } #endregion #region Request Handlers function Invoke-AddRequest { <# .SYNOPSIS Handles a request to add a user to the Administrators group. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Username, [Parameter(Mandatory)] [string]$ClientIdentity, [int]$RequestedDuration = 0 ) Write-Verbose "Processing ADD request for '$Username' from '$ClientIdentity'" # Validate that the client identity matches the requested user # Allow for different domain\user formats $clientUser = $ClientIdentity $requestedUser = $Username # Normalize usernames for comparison $normalizedClient = $clientUser -replace '^[^\\]+\\', '' $normalizedRequested = $requestedUser -replace '^[^\\]+\\', '' if ($normalizedClient -ne $normalizedRequested) { Write-RequestDeniedEvent -Username $Username -Reason "Client identity '$ClientIdentity' does not match requested username '$Username'" return New-Response -Success $false -Message "Access denied: You can only request admin rights for yourself." } # Check if user is allowed $userSid = Get-UserSID -Username $Username if (-not (Test-UserAllowed -Username $Username -UserSID $userSid)) { Write-RequestDeniedEvent -Username $Username -Reason "User is not in allowed list or is in denied list" return New-Response -Success $false -Message "Access denied: You are not authorized to request admin rights." } # Get validated duration $duration = Get-ValidatedDuration -RequestedDuration $RequestedDuration $expiresAt = (Get-Date).AddMinutes($duration) # Add user to Administrators group $addResult = Add-UserToLocalAdmins -Username $Username if (-not $addResult.Success) { Write-ErrorEvent -Message "Failed to add '$Username' to Administrators: $($addResult.Message)" return New-Response -Success $false -Message $addResult.Message } # Create scheduled task for removal $taskResult = New-AdminRemovalTask -Username $Username -ExecuteAt $expiresAt if (-not $taskResult.Success) { Write-WarningEvent -Message "User '$Username' added to Administrators but removal task could not be created: $($taskResult.Message)" # Still return success since the user was added, but warn about the task $message = "Admin rights granted until $($expiresAt.ToString('HH:mm:ss')). WARNING: Automatic removal task could not be created." } else { # Track active user in state Add-ActiveUser -Username $Username -ExpiresAt $expiresAt -TaskName $taskResult.TaskName $message = "Admin rights granted until $($expiresAt.ToString('HH:mm:ss'))." } # Log the event Write-AdminRightsGrantedEvent -Username $Username -DurationMinutes $duration -ExpiresAt $expiresAt return New-Response -Success $true -Message $message -ExpiresAt $expiresAt } function Invoke-RemoveRequest { <# .SYNOPSIS Handles a request to remove a user from the Administrators group. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Username, [Parameter(Mandatory)] [string]$ClientIdentity ) Write-Verbose "Processing REMOVE request for '$Username' from '$ClientIdentity'" # Validate that the client identity matches the requested user $normalizedClient = $ClientIdentity -replace '^[^\\]+\\', '' $normalizedRequested = $Username -replace '^[^\\]+\\', '' if ($normalizedClient -ne $normalizedRequested) { Write-RequestDeniedEvent -Username $Username -Reason "Client identity '$ClientIdentity' does not match requested username '$Username'" return New-Response -Success $false -Message "Access denied: You can only remove admin rights for yourself." } # Remove user from Administrators group $removeResult = Remove-UserFromLocalAdmins -Username $Username if ($removeResult.Success) { # Update state Remove-ActiveUser -Username $Username # Try to remove any pending removal task $activeUsers = Get-ActiveUsers $userEntry = $activeUsers | Where-Object { $_.Username -eq $Username } if ($userEntry -and $userEntry.TaskName) { Remove-AdminRemovalTask -TaskName $userEntry.TaskName -ErrorAction SilentlyContinue } # Log the event Write-AdminRightsRemovedEvent -Username $Username -Reason "UserRequest" return New-Response -Success $true -Message "Admin rights removed successfully." } else { return New-Response -Success $false -Message $removeResult.Message } } function Invoke-StatusRequest { <# .SYNOPSIS Handles a status query request. #> [CmdletBinding()] param( [string]$Username, [Parameter(Mandatory)] [string]$ClientIdentity ) Write-Verbose "Processing STATUS request from '$ClientIdentity'" $activeUsers = Get-ActiveUsers # If a specific username is requested, filter for that user if ($Username) { $normalizedRequested = $Username -replace '^[^\\]+\\', '' $normalizedClient = $ClientIdentity -replace '^[^\\]+\\', '' # Users can only query their own status if ($normalizedClient -ne $normalizedRequested) { return New-Response -Success $false -Message "Access denied: You can only query your own status." } $userEntry = $activeUsers | Where-Object { ($_.Username -replace '^[^\\]+\\', '') -eq $normalizedRequested } if ($userEntry) { $expiresAt = [datetime]::Parse($userEntry.ExpiresAt) $isAdmin = Test-UserIsLocalAdmin -Username $Username return [PSCustomObject]@{ success = $true message = "User has active admin rights." isAdmin = $isAdmin expiresAt = $userEntry.ExpiresAt grantedAt = $userEntry.GrantedAt } } else { $isAdmin = Test-UserIsLocalAdmin -Username $Username return [PSCustomObject]@{ success = $true message = if ($isAdmin) { "User is admin but not tracked by MakeMeAdminCLI." } else { "User does not have active admin rights." } isAdmin = $isAdmin } } } else { # Return general status (for the calling user) $normalizedClient = $ClientIdentity -replace '^[^\\]+\\', '' $userEntry = $activeUsers | Where-Object { ($_.Username -replace '^[^\\]+\\', '') -eq $normalizedClient } $isAdmin = Test-UserIsLocalAdmin -Username $ClientIdentity if ($userEntry) { return [PSCustomObject]@{ success = $true message = "You have active admin rights." isAdmin = $isAdmin expiresAt = $userEntry.ExpiresAt grantedAt = $userEntry.GrantedAt } } else { return [PSCustomObject]@{ success = $true message = if ($isAdmin) { "You are admin but not tracked by MakeMeAdminCLI." } else { "You do not have active admin rights." } isAdmin = $isAdmin } } } } function Invoke-Request { <# .SYNOPSIS Routes a request to the appropriate handler. #> [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject]$Request, [Parameter(Mandatory)] [string]$ClientIdentity ) $action = $Request.action $username = if ($Request.username) { $Request.username } else { $ClientIdentity } $duration = if ($Request.duration) { [int]$Request.duration } else { 0 } Write-RequestReceivedEvent -Username $ClientIdentity -Action $action -RequestedDuration $duration switch ($action.ToLower()) { 'add' { return Invoke-AddRequest -Username $username -ClientIdentity $ClientIdentity -RequestedDuration $duration } 'remove' { return Invoke-RemoveRequest -Username $username -ClientIdentity $ClientIdentity } 'status' { return Invoke-StatusRequest -Username $username -ClientIdentity $ClientIdentity } default { return New-Response -Success $false -Message "Unknown action: $action. Valid actions are: add, remove, status." } } } #endregion #region Main Service Loop function Start-ServiceLoop { <# .SYNOPSIS Main service loop that listens for and processes requests. #> [CmdletBinding()] param( [switch]$RunOnce, [int]$TimeoutSeconds = 30 ) $config = Get-MakeMeAdminConfig $pipeName = $config.PipeName Write-Verbose "Starting MakeMeAdminCLI service..." Write-Verbose "Pipe name: $pipeName" Write-Verbose "Config file: $(Join-Path $ModuleRoot 'config.json')" Write-Verbose "State file: $($config.StateFilePath)" # Initialize event log $null = Initialize-MakeMeAdminEventLog -EventSource $config.EventLogSource # Initialize state $null = Initialize-StateFile # Log service start Write-ServiceStartedEvent -AdditionalInfo "Listening on pipe: $pipeName" try { while ($script:Running) { $pipeServer = $null try { # Create new pipe server for each connection $pipeServer = New-NamedPipeServer -PipeName $pipeName if ($null -eq $pipeServer) { Write-Warning "Failed to create pipe server. Retrying in 5 seconds..." Start-Sleep -Seconds 5 continue } Write-Verbose "Waiting for connection on pipe: $pipeName" # Wait for a connection $pipeServer.WaitForConnection() Write-Verbose "Client connected" # Read the request FIRST (required before GetImpersonationUserName can work) $request = Read-PipeMessage -PipeServer $pipeServer -TimeoutSeconds $TimeoutSeconds # Now get client identity using GetImpersonationUserName (must be after reading data) $clientIdentity = "Unknown" try { $impersonationName = $pipeServer.GetImpersonationUserName() Write-Verbose "GetImpersonationUserName returned: '$impersonationName'" if ($impersonationName) { $clientIdentity = $impersonationName } } catch { Write-Warning "Could not get client identity via GetImpersonationUserName: $($_.Exception.Message)" } Write-Verbose "Client identity: $clientIdentity" if ($null -eq $request) { Write-Verbose "No valid request received" $response = New-Response -Success $false -Message "Invalid request format. Expected JSON." } else { # Process the request $response = Invoke-Request -Request $request -ClientIdentity $clientIdentity } # Send response Write-PipeResponse -PipeServer $pipeServer -Response $response # Disconnect the client $pipeServer.Disconnect() } catch { Write-Warning "Error in service loop: $($_.Exception.Message)" Write-ErrorEvent -Message "Service loop error: $($_.Exception.Message)" -Exception $_.Exception } finally { if ($null -ne $pipeServer) { try { $pipeServer.Dispose() } catch { } } } if ($RunOnce) { Write-Verbose "RunOnce mode - exiting after single request" break } } } finally { Write-ServiceStoppedEvent -Reason "Service loop terminated" } } function Stop-Service { <# .SYNOPSIS Signals the service to stop. #> [CmdletBinding()] param() $script:Running = $false Write-Verbose "Stop signal received" } #endregion #region Script Entry Point # Handle Ctrl+C gracefully $null = [Console]::TreatControlCAsInput = $false Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action { Stop-Service } | Out-Null # Start the service try { Start-ServiceLoop -RunOnce:$RunOnce -TimeoutSeconds $Timeout } catch { Write-Error "Fatal error in service: $($_.Exception.Message)" Write-ErrorEvent -Message "Fatal service error" -Exception $_.Exception exit 1 } exit 0 #endregion |