From d91409bb99a24801becfc3e77a40da443cd2d11a Mon Sep 17 00:00:00 2001 From: Stephan Yelle Date: Thu, 11 Dec 2025 16:35:27 -0500 Subject: [PATCH] Add samy.ps1 --- samy.ps1 | 2530 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2530 insertions(+) create mode 100644 samy.ps1 diff --git a/samy.ps1 b/samy.ps1 new file mode 100644 index 0000000..edf3f5e --- /dev/null +++ b/samy.ps1 @@ -0,0 +1,2530 @@ +#region Safely bypass Restricted Execution Policy +# Safely bypass Restricted Execution Policy +if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage' -or + (Get-ExecutionPolicy) -eq 'Restricted') { + + Write-Host "[Info] Relaunching with ExecutionPolicy Bypass..." -ForegroundColor Yellow + + # Rebuild the original argument list as a string to pass through + $argList = @() + foreach ($a in $args) { + if ($a -is [string]) { + # Quote and escape any existing quotes + $escaped = $a.Replace('"','`"') + $argList += "`"$escaped`"" + } else { + $argList += $a.ToString() + } + } + $argString = $argList -join ' ' + + if ($PSCommandPath) { + # Script saved on disk: re-run same file with same args + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "`"$PSCommandPath`"" $argString + } else { + # iwr | iex scenario: re-download SAMY and apply same args + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "& { iwr 'https://samy.svstools.ca' -UseBasicParsing | iex } $argString" + } + + exit +} + +# TLS and silent install defaults +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$ProgressPreference = 'SilentlyContinue' +$ConfirmPreference = 'None' +#endregion Safely bypass Restricted Execution Policy + +function Invoke-ScriptAutomationMonkey { + +<# +.SYNOPSIS + Main entry point for the Script Automation Monkey (SAMY) deployment tool. + +.DESCRIPTION + Starts the SAMY web UI or runs one of several headless modes for installing, + cleaning up, or offboarding the SVSMSP toolkit and related stack. + + Headless modes: + - Silent install of the SVSMSP toolkit (no UI) + - Toolkit cleanup and removal + - Full offboarding of the SVSMSP stack + - Datto RMM integration for: + * Site discovery and export + * Site variable push to registry + * Agent download and installation + * Saving a copy of the installer + - Printer and device helpers exposed through the UI endpoints + + When called with no parameters (or via iwr samy.svstools.ca | iex), + SAMY starts a local HTTP listener, renders the web UI, and executes + tasks selected in the browser. + +.PARAMETER SilentInstall + Run a default headless install of the SVSMSP toolkit without showing the UI. + +.PARAMETER Cleanup + Remove the SVSMSP toolkit, custom repository, and related deployment + artifacts without launching the UI. + +.PARAMETER Offboard + Perform a headless offboard of the SVSMSP stack: + uninstall integrated tools and then clean up the SVSMSP toolkit. + +.PARAMETER UseWebhook + Use the configured webhook to retrieve Datto RMM API credentials instead + of passing ApiUrl / ApiKey / ApiSecretKey directly. + +.PARAMETER WebhookPassword + Password or token used to authenticate to the Datto credential webhook. + +.PARAMETER ApiUrl + Base URL for the Datto RMM API when using direct API mode. + +.PARAMETER ApiKey + Datto RMM API key when using direct API mode. + +.PARAMETER ApiSecretKey + Datto RMM API secret key when using direct API mode. + +.PARAMETER FetchSites + Fetch the list of Datto RMM sites. Can be combined with SaveSitesList + and OutputFile to export the site list. + +.PARAMETER SaveSitesList + When used with FetchSites, save the Datto site list to the file + specified by OutputFile (CSV or JSON). + +.PARAMETER OutputFile + Target file name for the Datto site list when SaveSitesList is used. + Supports .csv and .json. Default is datto_sites.csv. + +.PARAMETER SiteUID + Datto RMM site UID used when pushing site variables or installing + the RMM agent for a specific site. + +.PARAMETER SiteName + Friendly Datto RMM site name used for logging only. + +.PARAMETER PushSiteVars + Fetch Datto RMM site variables and push them into the HKLM:\Software\SVS + Deployment registry key. + +.PARAMETER InstallRMM + Download and run the Datto RMM agent installer for the selected site. + +.PARAMETER SaveCopy + Download and save a copy of the Datto RMM agent installer to disk + without launching it. + +.EXAMPLE + iwr samy.svstools.ca | iex + + Download and launch SAMY in interactive UI mode on the local machine. + +.EXAMPLE + (iwr samy.svstools.ca -UseBasicParsing).Content | iex + Invoke-ScriptAutomationMonkey -SilentInstall + + Run a headless install of the SVSMSP toolkit (no UI) from the current session. + +.EXAMPLE + (iwr samy.svstools.ca -UseBasicParsing).Content | iex + Invoke-ScriptAutomationMonkey ` + -UseWebhook ` + -WebhookPassword 'MySecret' ` + -FetchSites ` + -SaveSitesList ` + -OutputFile 'sites.json' + + Fetch Datto RMM sites via webhook and save them to sites.json. + +#> + + # ============================================================== + # PARAMETERS (DRIVE MODES / PARAMETER SETS) + # + # Default UI: + # & (iwr samy.svstools.ca | iex) + # + # Headless modes: + # -SilentInstall Default SVSMSP toolkit install + # -Cleanup Remove toolkit, repo, registry + # -Offboard Full headless offboarding + # + # Datto fetch (sites only): + # Webhook: + # -UseWebhook -WebhookPassword 'pwd' -FetchSites ` + # [-SaveSitesList] [-OutputFile 'sites.json'] + # + # Direct: + # -ApiUrl 'https://api.example.com' ` + # -ApiKey 'YourApiKey' -ApiSecretKey 'YourSecretKey' ` + # -FetchSites [-SaveSitesList] [-OutputFile 'sites.json'] + # + # Datto install (single site actions): + # Webhook: + # -UseWebhook -WebhookPassword 'pwd' ` + # -SiteUID 'site-123' -SiteName 'Acme Corp' ` + # [-PushSiteVars] [-InstallRMM] [-SaveCopy] + # + # Direct: + # -ApiUrl 'https://api.example.com' -ApiKey 'k' -ApiSecretKey 's' ` + # -SiteUID 'site-123' -SiteName 'Acme Corp' ` + # [-PushSiteVars] [-InstallRMM] [-SaveCopy] + # ============================================================== + + [CmdletBinding( + DefaultParameterSetName = 'UI', + SupportsShouldProcess = $true, + ConfirmImpact = 'Medium' + )] + param( + # Toolkit-only mode + [Parameter(Mandatory, ParameterSetName = 'Toolkit')] + [switch]$SilentInstall, + + # Remove Toolkit + [Parameter(Mandatory, ParameterSetName = 'Cleanup')] + [switch]$Cleanup, + + # Headless offboarding + [Parameter(Mandatory, ParameterSetName = 'Offboard')] + [switch]$Offboard, + + # Datto headless mode (shared switches) + + [Parameter(Mandatory, ParameterSetName = 'DattoFetch')] + [Parameter(Mandatory, ParameterSetName = 'DattoInstall')] + [switch]$UseWebhook, + + [Parameter(Mandatory, ParameterSetName = 'DattoFetch')] + [Parameter(Mandatory, ParameterSetName = 'DattoInstall')] + [string]$WebhookPassword, + + [string]$WebhookUrl = $Global:DattoWebhookUrl, + + # Only DattoFetch uses these + [Parameter(ParameterSetName = 'DattoFetch')] + [switch]$FetchSites, + + [Parameter(ParameterSetName = 'DattoFetch')] + [switch]$SaveSitesList, + + [Parameter(ParameterSetName = 'DattoFetch')] + [ValidatePattern('\.csv$|\.json$')] + [string]$OutputFile = 'datto_sites.csv', + + # Only DattoInstall uses these + [Parameter(Mandatory, ParameterSetName = 'DattoInstall')] + [string]$SiteUID, + + [Parameter(Mandatory, ParameterSetName = 'DattoInstall')] + [string]$SiteName, + + [Parameter(ParameterSetName = 'DattoInstall')] + [switch]$PushSiteVars, + + [Parameter(ParameterSetName = 'DattoInstall')] + [switch]$InstallRMM, + + [Parameter(ParameterSetName = 'DattoInstall')] + [switch]$SaveCopy + ) + + # Global variables + + # Listening port for HTTP UI + $Port = 8082 + + # Configurable endpoints + $Global:DattoWebhookUrl = 'https://bananas.svstools.ca/dattormm' + + # SAMY asset config (change branch or base once and it updates everything) + $Script:SamyBranch = 'beta' # 'main' or 'beta' + $Script:SamyRepoBase = 'https://git.svstools.ca/SVS_Public_Repo/SAMY/raw/branch' + + # Top-left corner logo (SVS) + $Script:SamyTopLogoUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SVS_logo.svg" + + # Background SAMY image used in CSS + $Script:SamyBgLogoUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SAMY.png" + + $Script:SamyFaviconUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SVS_Favicon.ico" + $Script:SamyCssUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.css?raw=1" + $Script:SamyJsUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.js?raw=1" + + # Initialize a global in-memory log cache + if (-not $Global:LogCache -or -not ($Global:LogCache -is [System.Collections.ArrayList])) { + $Global:LogCache = [System.Collections.ArrayList]::new() + } + + # SVS Module + + function Initialize-NuGetProvider { + [CmdletBinding()] + param() + + # Silent defaults + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $ProgressPreference = 'SilentlyContinue' + $ConfirmPreference = 'None' + + # Pre-create folder if running as SYSTEM + $provPath = "$env:ProgramData\PackageManagement\ProviderAssemblies" + if (-not (Test-Path $provPath)) { + try { + New-Item -Path $provPath -ItemType Directory -Force -ErrorAction Stop | Out-Null + Write-LogHybrid "Created missing provider folder: $provPath" Info Bootstrap -LogToEvent + } catch { + Write-LogHybrid "Failed to create provider folder: $($_.Exception.Message)" Warning Bootstrap -LogToEvent + } + } + + # Ensure PowerShellGet is available + if (-not (Get-Command Install-PackageProvider -ErrorAction SilentlyContinue)) { + try { + Install-Module PowerShellGet -Force -AllowClobber -Confirm:$false -ErrorAction Stop + Write-LogHybrid "Installed PowerShellGet module" Info Bootstrap -LogToEvent + } catch { + Write-LogHybrid "PowerShellGet install failed: $($_.Exception.Message)" Error Bootstrap -LogToEvent + } + } + + # Ensure PackageManagement is up-to-date + $pkgMgmtVersion = (Get-Module PackageManagement -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).Version + if ($pkgMgmtVersion -lt [Version]"1.3.1") { + try { + Install-Module PackageManagement -Force -AllowClobber -Confirm:$false -ErrorAction Stop + Write-LogHybrid "Updated PackageManagement to latest version" Info Bootstrap -LogToEvent + } catch { + Write-LogHybrid "PackageManagement update failed: $($_.Exception.Message)" Warning Bootstrap -LogToEvent + } + } + + # Import modules silently + Import-Module PackageManagement -Force -ErrorAction SilentlyContinue | Out-Null + Import-Module PowerShellGet -Force -ErrorAction SilentlyContinue | Out-Null + + # Trust PSGallery if not already + $gallery = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue + if ($gallery -and $gallery.InstallationPolicy -ne 'Trusted') { + try { + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop + Write-LogHybrid "PSGallery marked as Trusted" Info Bootstrap -LogToEvent + } catch { + Write-LogHybrid "Failed to trust PSGallery: $($_.Exception.Message)" Warning Bootstrap -LogToEvent + } + } + + # Ensure NuGet is installed silently + $nuget = Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue + if (-not $nuget) { + try { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Confirm:$false -ErrorAction Stop + $nuget = Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue + Write-LogHybrid "Installed NuGet provider v$($nuget.Version)" Info Bootstrap -LogToEvent + } catch { + Write-LogHybrid "NuGet install failed: $($_.Exception.Message)" Error Bootstrap -LogToEvent + } + } else { + Write-LogHybrid "NuGet provider already present (v$($nuget.Version))" Info Bootstrap -LogToEvent + } + + # Final import check + try { + Import-PackageProvider -Name NuGet -Force -ErrorAction Stop | Out-Null + } catch { + Write-LogHybrid "NuGet provider import failed: $($_.Exception.Message)" Error Bootstrap -LogToEvent + } + } + + function Install-SVSMSP { + param ( + [switch] $Cleanup, + [switch] $InstallToolkit, + [Parameter(Mandatory = $false)][array] $AllModules = @(@{ ModuleName = "SVS_Toolkit" }, @{ ModuleName = "SVSMSP" }), + [Parameter(Mandatory = $false)][array] $AllRepositories = @(@{ RepoName = "SVS_Repo" }, @{ RepoName = "SVS_Toolkit" }), + [Parameter(Mandatory = $false)][string] $NewModuleName = "SVSMSP", + [Parameter(Mandatory = $false)][string] $NewRepositoryName = "SVS_Repo", + [Parameter(Mandatory = $false)][string] $NewRepositoryURL = "http://proget.svstools.ca:8083/nuget/SVS_Repo/" + ) + + function Start-Cleanup { + Write-LogHybrid "Cleanup mode enabled. Starting cleanup..." Info SVSModule + + # Attempt to uninstall all versions of SVSMSP + try { + Uninstall-Module -Name SVSMSP -AllVersions -Force -ErrorAction Stop + Write-LogHybrid "SVSMSP module uninstalled from system." Success SVSModule -LogToEvent + } + catch { + if ($_.Exception.Message -match 'No match was found') { + Write-LogHybrid "No existing SVSMSP module found to uninstall." Warning SVSModule -LogToEvent + } + else { + Write-LogHybrid "Failed to uninstall SVSMSP: $($_.Exception.Message)" Error SVSModule -LogToEvent + } + } + + # Remove the custom repository if registered + if (Get-PSRepository -Name SVS_Repo -ErrorAction SilentlyContinue) { + try { + Unregister-PSRepository -Name SVS_Repo -ErrorAction Stop + Write-LogHybrid "SVS_Repo repository unregistered." Success SVSModule -LogToEvent + } + catch { + Write-LogHybrid "Failed to unregister SVS_Repo: $($_.Exception.Message)" Error SVSModule -LogToEvent + } + } + + # Remove from current session + if (Get-Module -Name SVSMSP) { + try { + Remove-Module SVSMSP -Force -ErrorAction Stop + Write-LogHybrid "SVSMSP module removed from current session." Success SVSModule -LogToEvent + } + catch { + Write-LogHybrid "Failed to remove SVSMSP from session: $($_.Exception.Message)" Error SVSModule -LogToEvent + } + } + + # CSCE cleanup + $cscePath = 'C:\CSCE' + if (Test-Path $cscePath) { + try { + Remove-Item -Path $cscePath -Recurse -Force + Write-LogHybrid "Deleted '$cscePath' contents." Success SVSModule -LogToEvent + } catch { + Write-LogHybrid "Failed to delete '$cscePath': $($_.Exception.Message)" Warning SVSModule -LogToEvent + } + } + } + + function Remove-SVSDeploymentRegKey { + $regKey = 'HKLM:\Software\SVS' + + try { + if (Test-Path $regKey) { + Remove-Item -Path $regKey -Recurse -Force + Write-LogHybrid "Registry key '$regKey' deleted successfully." Success SVSModule -LogToEvent + } + else { + Write-LogHybrid "Registry key '$regKey' not found; nothing to delete." Info SVSModule -LogToEvent + } + } + catch { + Write-LogHybrid "Failed to delete registry key '$regKey': $($_.Exception.Message)" Error SVSModule -LogToEvent + } + } + + function Repair-SVSMspEventLogBinding { + param( + [string]$EventSource = "SVSMSP_Module", + [string]$TargetLog = "SVSMSP Events" + ) + + Write-LogHybrid "Checking Event Log binding for source '$EventSource'..." Info SVSModule -LogToEvent + + try { + if (-not [System.Diagnostics.EventLog]::SourceExists($EventSource)) { + Write-LogHybrid "Event source '$EventSource' not found. Nothing to repair." Info SVSModule -LogToEvent + return + } + + $currentLog = [System.Diagnostics.EventLog]::LogNameFromSourceName($EventSource, '.') + } + catch { + Write-LogHybrid "Failed to query Event Log binding for '$EventSource': $($_.Exception.Message)" Warning SVSModule -LogToEvent + return + } + + if (-not $currentLog) { + Write-LogHybrid "Could not determine current log for event source '$EventSource'. Skipping repair." Warning SVSModule -LogToEvent + return + } + + if ($currentLog -eq $TargetLog) { + Write-LogHybrid "Event source '$EventSource' already bound to '$TargetLog'." Info SVSModule -LogToEvent + return + } + + Write-LogHybrid "Rebinding event source '$EventSource' from '$currentLog' to '$TargetLog'..." Warning SVSModule -LogToEvent + + try { + [System.Diagnostics.EventLog]::DeleteEventSource($EventSource) + + if (-not [System.Diagnostics.EventLog]::Exists($TargetLog)) { + New-EventLog -LogName $TargetLog -Source $EventSource -ErrorAction Stop + } + else { + New-EventLog -LogName $TargetLog -Source $EventSource -ErrorAction Stop + } + + Write-LogHybrid "Event source '$EventSource' rebound to '$TargetLog'." Success SVSModule -LogToEvent + } + catch { + Write-LogHybrid "Failed to rebind event source '$EventSource' to log '$TargetLog': $($_.Exception.Message)" Error SVSModule -LogToEvent + } + } + + function Start-ToolkitInstallation { + Initialize-NuGetProvider + Start-Cleanup + + Write-LogHybrid "Registering repo $NewRepositoryName..." Info SVSModule -LogToEvent + if (-not (Get-PSRepository -Name $NewRepositoryName -ErrorAction SilentlyContinue)) { + Register-PSRepository -Name $NewRepositoryName -SourceLocation $NewRepositoryURL -InstallationPolicy Trusted + } + + Write-LogHybrid "Installing module $NewModuleName..." Info SVSModule -LogToEvent + Install-Module -Name $NewModuleName -Repository $NewRepositoryName -Scope AllUsers -Force + + Repair-SVSMspEventLogBinding -EventSource "SVSMSP_Module" -TargetLog "SVSMSP Events" + + Write-LogHybrid "Toolkit installation completed." Success SVSModule -LogToEvent + } + + Write-LogHybrid "Install-SVSMSP called" Info SVSModule -LogToEvent + + if ($Cleanup) { + Start-Cleanup + Remove-SVSDeploymentRegKey + return + } + + if ($InstallToolkit) { + Start-ToolkitInstallation + return + } + + # Default if no switch passed: + Start-ToolkitInstallation + } + + # Write-Log (fallback + hybrid) + + function Write-LogHelper { + <# + .SYNOPSIS + Fallback logging utility with console/file output and Windows Event Log support. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$Message, + + [ValidateSet("Info", "Warning", "Error", "Success", "General")] + [string]$Level = "Info", + + [string]$TaskCategory = "GeneralTask", + + [switch]$LogToEvent = $false, + + [string]$EventSource = "SAMY", + + [string]$EventLog = "SVSMSP Events", + + [int]$CustomEventID, + + [string]$LogFile, + + [switch]$PassThru + ) + + $EventID = if ($CustomEventID) { $CustomEventID } else { + switch ($Level) { + "Info" { 1000 } + "Warning" { 2000 } + "Error" { 3000 } + "Success" { 4000 } + default { 1000 } + } + } + + $Color = switch ($Level) { + "Info" { "Cyan" } + "Warning" { "Yellow" } + "Error" { "Red" } + "Success" { "Green" } + default { "White" } + } + + $FormattedMessage = "[$Level] [$TaskCategory] $Message (Event ID: $EventID)" + Write-Host $FormattedMessage -ForegroundColor $Color + + if (-not $Global:LogCache -or -not ($Global:LogCache -is [System.Collections.ArrayList])) { + $Global:LogCache = [System.Collections.ArrayList]::new() + } + + $logEntry = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + Level = $Level + Message = $FormattedMessage + } + [void]$Global:LogCache.Add($logEntry) + + if ($LogFile) { + try { + "$($logEntry.Timestamp) $FormattedMessage" | + Out-File -FilePath $LogFile -Append -Encoding UTF8 + } + catch { + Write-Host "[Warning] Failed to write to log file: $($_.Exception.Message)" -ForegroundColor Yellow + } + } + + if ($LogToEvent) { + + if (-not $Global:EventSourceInitState) { + $Global:EventSourceInitState = @{} + } + + $EntryType = switch ($Level) { + "Info" { "Information" } + "Warning" { "Warning" } + "Error" { "Error" } + "Success" { "Information" } + default { "Information" } + } + + $sourceKey = "$EventLog|$EventSource" + + if (-not $Global:EventSourceInitState.ContainsKey($sourceKey) -or + -not $Global:EventSourceInitState[$sourceKey]) { + + try { + if (-not [System.Diagnostics.EventLog]::SourceExists($EventSource)) { + + $isAdmin = $false + try { + $current = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($current) + $isAdmin = $principal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator + ) + } + catch { + $isAdmin = $false + } + + if ($isAdmin) { + New-EventLog -LogName $EventLog -Source $EventSource -ErrorAction Stop + } + else { + $helperScript = @" +if (-not [System.Diagnostics.EventLog]::SourceExists('$EventSource')) { + New-EventLog -LogName '$EventLog' -Source '$EventSource' +} +"@ + + $tempPath = [System.IO.Path]::Combine( + $env:TEMP, + "Init_${EventLog}_$EventSource.ps1".Replace(' ', '_') + ) + + $helperScript | Set-Content -Path $tempPath -Encoding UTF8 + + try { + $null = Start-Process -FilePath "powershell.exe" ` + -ArgumentList "-ExecutionPolicy Bypass -File `"$tempPath`"" ` + -Verb RunAs -Wait -PassThru + } + catch { + Write-Host "[Warning] Auto-elevation to create Event Log '$EventLog' / source '$EventSource' failed: $($_.Exception.Message)" -ForegroundColor Yellow + } + finally { + Remove-Item -Path $tempPath -ErrorAction SilentlyContinue + } + } + } + + if ([System.Diagnostics.EventLog]::SourceExists($EventSource)) { + $Global:EventSourceInitState[$sourceKey] = $true + } + else { + $Global:EventSourceInitState[$sourceKey] = $false + Write-Host "[Warning] Event source '$EventSource' does not exist and could not be created. Skipping Event Log write." -ForegroundColor Yellow + } + } + catch { + Write-Host "[Warning] Failed to initialize Event Log '$EventLog' / source '$EventSource': $($_.Exception.Message)" -ForegroundColor Yellow + $Global:EventSourceInitState[$sourceKey] = $false + } + } + + if ($Global:EventSourceInitState[$sourceKey]) { + try { + $EventMessage = "TaskCategory: $TaskCategory | Message: $Message" + Write-EventLog -LogName $EventLog -Source $EventSource -EntryType $EntryType -EventId $EventID -Message $EventMessage + } + catch { + Write-Host "[Warning] Failed to write to Event Log: $($_.Exception.Message)" -ForegroundColor Yellow + } + } + } + + if ($PassThru) { + return $logEntry + } + } + + function Write-LogHybrid { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [ValidateSet("Info", "Warning", "Error", "Success", "General")] + [string]$Level = "Info", + + [string]$TaskCategory = "GeneralTask", + + [switch]$LogToEvent, + + [string]$EventSource = "SVSMSP_Module", + + [string]$EventLog = "SVSMSP Events", + + [int]$CustomEventID, + + [string]$LogFile, + + [switch]$PassThru, + + [ValidateSet("Black","DarkGray","Gray","White","Red","Green","Blue","Yellow","Magenta","Cyan")] + [string]$ForegroundColorOverride + ) + + $formatted = "[$Level] [$TaskCategory] $Message" + + $invokeParams = @{ + Message = $Message + Level = $Level + TaskCategory = $TaskCategory + LogToEvent = $LogToEvent + EventSource = $EventSource + EventLog = $EventLog + } + + if ($PSBoundParameters.ContainsKey('CustomEventID')) { + $invokeParams.CustomEventID = $CustomEventID + } + if ($PSBoundParameters.ContainsKey('LogFile')) { + $invokeParams.LogFile = $LogFile + } + if ($PassThru) { + $invokeParams.PassThru = $true + } + + if ($PSBoundParameters.ContainsKey('ForegroundColorOverride')) { + Write-Host $formatted -ForegroundColor $ForegroundColorOverride + + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log @invokeParams + } + else { + Write-LogHelper @invokeParams + } + } + else { + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log @invokeParams + } + else { + Write-LogHelper @invokeParams + } + } + } + + # Computer rename helper + + function Test-ComputerName { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ([string]::IsNullOrWhiteSpace($Name)) { return $false } + if ($Name.Length -gt 15) { return $false } + if ($Name -notmatch '^[A-Za-z0-9-]+$') { return $false } + return $true + } + + # Task menu definition + + $Global:SamyTasks = @( + # On-Boarding, left column + @{ Id='setSVSPowerplan'; Name='setSVSPowerplan'; Label='Set SVS Powerplan'; HandlerFn='Invoke-SetSVSPowerPlan'; Page='onboard'; Column='left' }, + @{ Id='installSVSMSPModule'; Name='installSVSMSPModule'; Label='Install SVSMSP Module'; HandlerFn='Invoke-InstallSVSMSP'; Page='onboard'; Column='left' }, + @{ Id='installCyberQP'; Name='installCyberQP'; Label='Install CyberQP'; HandlerFn='Invoke-InstallCyberQP'; Page='onboard'; Column='left' }, + @{ Id='installHelpDesk'; Name='installHelpDesk'; Label='Install HelpDesk'; HandlerFn='Invoke-InstallHelpDesk'; Page='onboard'; Column='left' }, + @{ Id='installThreatLocker'; Name='installThreatLocker'; Label='Install ThreatLocker'; HandlerFn='Invoke-InstallThreatLocker'; Page='onboard'; Column='left' }, + @{ Id='installRocketCyber'; Name='installRocketCyber'; Label='Install RocketCyber'; HandlerFn='Invoke-InstallRocketCyber'; Page='onboard'; Column='left' }, + @{ Id='installDattoRMM'; Name='installDattoRMM'; Label='Install DattoRMM'; HandlerFn='Invoke-InstallDattoRMM'; Page='onboard'; Column='left'; + SubOptions= @( + @{ Value='inputVar'; Label='Copy Site Variables' }, + @{ Value='rmm'; Label='Install RMM Agent' }, + @{ Value='exe'; Label='Download Executable' } + ) + }, + + # On-Boarding, right column + @{ Id='enableBitLocker'; Name='EnableBitLocker'; Label='Enable BitLocker'; HandlerFn='Set-SVSBitLocker'; Page='onboard'; Column='right' }, + @{ Id='setEdgeDefaultSearch'; Name='setedgedefaultsearch'; Label='Set Edge Default Search'; Tooltip='Will configure Edge to use Google as default search provider'; HandlerFn='Invoke-SetEdgeDefaultSearchEngine'; Page='onboard'; Column='right' }, + + # Off-Boarding + @{ Id='offUninstallCyberQP'; Name='offUninstallCyberQP'; Label='Uninstall CyberQP'; HandlerFn='Invoke-UninstallCyberQP'; Page='offboard' }, + @{ Id='offUninstallHelpDesk'; Name='offUninstallHelpDesk'; Label='Uninstall HelpDesk'; HandlerFn='Invoke-UninstallHelpDesk'; Page='offboard' }, + @{ Id='offUninstallThreatLocker'; Name='offUninstallThreatLocker'; Label='Uninstall ThreatLocker'; HandlerFn='Invoke-UninstallThreatLocker'; Page='offboard' }, + @{ Id='offUninstallRocketCyber'; Name='offUninstallRocketCyber'; Label='Uninstall RocketCyber'; HandlerFn='Invoke-UninstallRocketCyber'; Page='offboard' }, + @{ Id='offCleanupSVSMSPModule'; Name='offCleanupSVSMSPModule'; Label='Cleanup SVSMSP Toolkit'; HandlerFn='Invoke-CleanupSVSMSP'; Page='offboard' }, + + # Tweaks + @{ Id='disableAnimations'; Name='disableAnimations'; Label='Disable Animations'; HandlerFn='Disable-Animations'; Page='tweaks' }, + + # SVS Apps + @{ Id='wingetLastpass'; Name='wingetLastpass'; Label='LastPass Desktop App'; HandlerFn='Install-WingetLastPass'; Page='SVSApps' }, + @{ Id='wingetChrome'; Name='wingetChrome'; Label='Google Chrome'; HandlerFn='Invoke-InstallChrome'; Page='SVSApps' }, + @{ Id='wingetAcrobat'; Name='wingetAcrobat'; Label='Adobe Acrobat Reader (64-bit)'; HandlerFn='Invoke-InstallAcrobat'; Page='SVSApps' } + ) + + Write-LogHybrid "Tasks by page: onboard=$( + ($Global:SamyTasks | Where-Object Page -eq 'onboard').Count + ) offboard=$( + ($Global:SamyTasks | Where-Object Page -eq 'offboard').Count + ) tweaks=$( + ($Global:SamyTasks | Where-Object Page -eq 'tweaks').Count + ) apps=$( + ($Global:SamyTasks | Where-Object Page -eq 'SVSApps').Count + )" Info UI -LogToEvent + + function Publish-Checkboxes { + param( + [Parameter(Mandatory)][string]$Page, + [string]$Column + ) + + $tasks = $Global:SamyTasks | Where-Object Page -EQ $Page + + if (-not [string]::IsNullOrEmpty($Column)) { + $tasks = $tasks | Where-Object Column -EQ $Column + } + + ( + $tasks | + ForEach-Object { + $taskId = $_.Id + $tooltip = if ($_.PSObject.Properties.Name -contains 'Tooltip' -and $_.Tooltip) { + " title='$($_.Tooltip)'" + } else { '' } + + $html = " $($_.Label)" + + if ($_.SubOptions) { + $subHtml = ( + $_.SubOptions | + ForEach-Object { + "" + } + ) -join "`n" + + $html += @" + +"@ + } + + $html + } + ) -join "`n" + } + + function Get-ModuleVersionHtml { + $mod = Get-Module -ListAvailable -Name SVSMSP | Sort-Object Version -Descending | Select-Object -First 1 + + $branchDisplay = switch ($Script:SamyBranch.ToLower()) { + 'main' { 'Main / Stable' } + 'beta' { 'Beta' } + default { $Script:SamyBranch } + } + + if ($mod) { + return "
+ Module Version: $($mod.Version)
+ UI Branch: $branchDisplay +
" + } + + return "
SVSMSP_Module not found
" + } + + # Server helpers + + function Get-NextFreePort { + param([int]$Start = $Port) + for ($p = [Math]::Max(1024,$Start); $p -lt 65535; $p++) { + $l = [System.Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, $p) + try { + $l.Start(); + $l.Stop(); + return $p + } catch {} + } + throw "No free TCP port available." + } + + function Start-Server { + $Global:Listener = [System.Net.HttpListener]::new() + $primaryPrefix = "http://localhost:$Port/" + $wildcardPrefix = "http://+:$Port/" + + try { + $Global:Listener.Prefixes.Add($primaryPrefix) + $Global:Listener.Start() + Write-LogHybrid "Listening on $primaryPrefix" Info Server -LogToEvent + } + catch [System.Net.HttpListenerException] { + if ($_.Exception.ErrorCode -eq 5) { + Write-LogHybrid "Access denied on $primaryPrefix. Attempting URL ACL..." Warning Server -LogToEvent + try { + $user = "$env:USERDOMAIN\$env:USERNAME" + if (-not $user.Trim()) { $user = $env:USERNAME } + Start-Process -FilePath "netsh" -ArgumentList "http add urlacl url=$wildcardPrefix user=`"$user`" listen=yes" -Verb RunAs -WindowStyle Hidden -Wait + $Global:Listener = [System.Net.HttpListener]::new() + $Global:Listener.Prefixes.Add($wildcardPrefix) + $Global:Listener.Start() + Write-LogHybrid "Listening on $wildcardPrefix (URL ACL added for $user)" Success Server -LogToEvent + } catch { + Write-LogHybrid "URL ACL registration failed: $($_.Exception.Message)" Error Server -LogToEvent + return + } + } + elseif ($_.Exception.NativeErrorCode -in 32,183) { + $old = $Port + $Port = Get-NextFreePort -Start ($Port + 1) + $Global:Listener = [System.Net.HttpListener]::new() + $primaryPrefix = "http://localhost:$Port/" + $Global:Listener.Prefixes.Add($primaryPrefix) + $Global:Listener.Start() + Write-LogHybrid "Port $old busy. Listening on $primaryPrefix" Warning Server -LogToEvent + } + else { + Write-LogHybrid "HttpListener start failed: $($_.Exception.Message)" Error Server -LogToEvent + return + } + } + + try { + while ($Global:Listener.IsListening) { + $ctx = $Global:Listener.GetContext() + try { + Dispatch-Request $ctx + } catch { + Write-LogHybrid "Dispatch error: $($_.Exception.Message)" Error Server -LogToEvent + } + } + } + finally { + $Global:Listener.Close() + Write-LogHybrid "Listener closed." Info Server -LogToEvent + } + } + + # UI HTML helpers + + function Get-RemoteText { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$Url + ) + + try { + $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -ErrorAction Stop + return $resp.Content + } + catch { + Write-LogHybrid "Get-RemoteText failed for ${Url}: $($_.Exception.Message)" Warning UI -LogToEvent + return "" + } + } + + function Get-UIHtml { + param([string]$Page = 'onboard') + if (-not $Page) { $Page = 'onboard' } + + $onboardLeft = Publish-Checkboxes -Page 'onboard' -Column 'left' + $onboardRight = Publish-Checkboxes -Page 'onboard' -Column 'right' + $offboard = Publish-Checkboxes -Page 'offboard' -Column '' + $tweaks = Publish-Checkboxes -Page 'tweaks' -Column '' + $apps = Publish-Checkboxes -Page 'SVSApps' -Column '' + + $tasksJsAll = ( + $Global:SamyTasks | ForEach-Object { + " { id: '$($_.Id)', handler: '/$($_.Name)', label: '$($_.Label)' }" + } + ) -join ",`n" + + $branchDisplay = switch ($Script:SamyBranch.ToLower()) { + 'main' { 'Main / Stable' } + 'beta' { 'Beta' } + default { $Script:SamyBranch } + } + + $cssContent = Get-RemoteText -Url $Script:SamyCssUrl + $jsContent = Get-RemoteText -Url $Script:SamyJsUrl + + if ($cssContent) { + $pattern = 'background-image:\s*url\("SAMY\.png"\);?' + $replacement = "background-image: url('$Script:SamyBgLogoUrl');" + $cssContent = [regex]::Replace($cssContent, $pattern, $replacement) + } + + $htmlTemplate = @" + + + + + +Script Automation Monkey + + + + + +
+
+ SVS Logo + {{moduleVersion}} +
+ +
+ Script Automation Monkey (Yeah!) +
+ +
+
+ +
+ +
+
+

On-Boarding

+

This new deployment method ensures everything is successfully deployed with greater ease!

+ +
+
+

SVSMSP Stack

+ + {{onboardLeftColumn}} +
+
+

Optional

+ + {{onboardRightColumn}} + +
+ +
+ + +
+
+ + + + + +
+ +
+

Off-Boarding

+
+
+

Remove Stack

+ + {{offboardCheckboxes}} +
+
+
+ +
+

Tweaks

+
+
+

Tweaks

+ {{tweaksCheckboxes}} +
+
+
+ +
+

SVS APPs

+
+
+

Applications

+ {{appsCheckboxes}} +
+
+
+ +
+

Devices

+

Manage printers and other client devices.

+ +
+ +
+ + +
+
+ + + + +
+
+
+ + + + + +
+ + +
+ + + +"@ + + $html = $htmlTemplate + $html = $html.Replace('{{moduleVersion}}', (Get-ModuleVersionHtml)) + $html = $html.Replace('{{onboardLeftColumn}}', $onboardLeft) + $html = $html.Replace('{{onboardRightColumn}}', $onboardRight) + $html = $html.Replace('{{offboardCheckboxes}}', $offboard) + $html = $html.Replace('{{tweaksCheckboxes}}', $tweaks) + $html = $html.Replace('{{appsCheckboxes}}', $apps) + $html = $html.Replace('{{tasksJsAll}}', $tasksJsAll) + $html = $html.Replace('{{defaultPage}}', $Page) + + return $html + } + + # HTTP responder helpers + + function Send-Text { + param($Context, $Text) + if (-not $Context -or -not $Context.Response) { + return + } + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + $Context.Response.ContentType = 'text/plain' + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes,0,$bytes.Length) + $Context.Response.OutputStream.Close() + } + + function Send-HTML { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][object] $Context, + [Parameter(Mandatory = $true)][string] $Html + ) + if (-not $Context -or -not $Context.Response) { + return + } + $bytes = [Text.Encoding]::UTF8.GetBytes($Html) + $Context.Response.ContentType = 'text/html' + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $Context.Response.OutputStream.Close() + } + + function Send-JSON { + [CmdletBinding()] + param( + $Context, + $Object + ) + + if (-not $Context -or -not $Context.Response) { + return + } + + try { + if ($null -eq $Object) { + Write-LogHybrid "Send-JSON called with `$null object; returning empty JSON array." Warning Printers -LogToEvent + $json = '[]' + } + else { + try { + $json = $Object | ConvertTo-Json -Depth 5 -ErrorAction Stop + } + catch { + Write-LogHybrid "Send-JSON serialization failed: $($_.Exception.Message); returning empty JSON array." Error Printers -LogToEvent + $json = '[]' + } + } + + $json = [string]$json + + $bytes = [Text.Encoding]::UTF8.GetBytes($json) + $Context.Response.ContentType = 'application/json' + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $Context.Response.OutputStream.Close() + } + catch { + Write-LogHybrid "Send-JSON fatal error: $($_.Exception.Message)" Error Printers -LogToEvent + try { + $fallback = '[]' + $bytes = [Text.Encoding]::UTF8.GetBytes($fallback) + $Context.Response.ContentType = 'application/json' + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + $Context.Response.OutputStream.Close() + } + catch { + } + } + } + + function Invoke-TasksCompleted { + param($Context) + + Write-LogHybrid "All UI-selected tasks processed" Info UI -LogToEvent + Send-Text $Context "Tasks completion acknowledged." + } + + # Datto handlers (HTTP side) + + function Invoke-FetchSites { + param($Context) + + try { + $raw = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd() + $pw = (ConvertFrom-Json $raw).password + + $Global:WebhookPassword = $pw + + $sites = Install-DattoRMM ` + -UseWebhook ` + -WebhookPassword $pw ` + -FetchSites ` + -SaveSitesList:$SaveSitesList ` + -OutputFile $OutputFile + + Send-JSON $Context $sites + } + catch { + Write-LogHybrid "Invoke-FetchSites error: $($_.Exception.Message)" Error DattoRMM -LogToEvent + $Context.Response.StatusCode = 500 + Send-Text $Context "Internal server error fetching sites." + } + } + + # Onboarding handlers + + function Invoke-SetSVSPowerPlan { + param($Context) + + Set-SVSPowerPlan + + Write-LogHybrid "PowerPlan set" Success OnBoard + Send-Text $Context "PowerPlan applied" + } + + function Invoke-InstallSVSMSP { + param($Context) + Write-LogHybrid "HTTP trigger: Invoke-InstallSVSMSP" Info OnBoard + try { + Install-SVSMSP -InstallToolkit + Send-Text $Context "SVSMSP Module installed/updated." + } catch { + Write-LogHybrid "Error in Install-SVSMSP: $_" Error OnBoard + Send-Text $Context "ERROR: $_" + } + } + + function Invoke-InstallCyberQP { + param($Context) + + Install-CyberQP + + Write-LogHybrid "CyberQP installed" Success OnBoard + Send-Text $Context "CyberQP installed" + } + + function Invoke-InstallThreatLocker { + param($Context) + + Install-ThreatLocker + + Write-LogHybrid "ThreatLocker installed" Success OnBoard + Send-Text $Context "ThreatLocker installed" + } + + function Invoke-InstallRocketCyber { + param($Context) + + Install-RocketCyber + + Write-LogHybrid "RocketCyber installed" Success OnBoard + Send-Text $Context "RocketCyber installed" + } + + function Invoke-InstallHelpDesk { + param($Context) + + Install-svsHelpDesk + + Write-LogHybrid "SVS HelpDesk installed" Success OnBoard + Send-Text $Context "SVS HelpDesk installed" + } + + function Invoke-SetEdgeDefaultSearchEngine { + param($Context) + + try { + Write-LogHybrid "Configuring Edge default search provider" Info OnBoard + set-EdgeDefaultSearchEngine + Write-LogHybrid "Edge default search set to Google" Success OnBoard + Send-Text $Context "Edge default search provider configured." + } catch { + Write-LogHybrid "Failed to set Edge default search: $($_.Exception.Message)" Error OnBoard + Send-Text $Context "ERROR: $($_.Exception.Message)" + } + } + + function Invoke-RenameComputer { + param($Context) + + try { + if ($Context.Request.HttpMethod -ne 'POST') { + $Context.Response.StatusCode = 405 + Send-Text $Context 'Use POST' + return + } + + $rawBody = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd() + if (-not $rawBody) { + $Context.Response.StatusCode = 400 + Send-Text $Context 'Missing request body.' + return + } + + try { + $body = $rawBody | ConvertFrom-Json + } catch { + $Context.Response.StatusCode = 400 + Send-Text $Context 'Invalid JSON body.' + return + } + + $newName = $body.newName + + if (-not (Test-ComputerName -Name $newName)) { + Write-LogHybrid "RenameComputer: invalid computer name '$newName'." Error OnBoard -LogToEvent + $Context.Response.StatusCode = 400 + Send-JSON $Context @{ + Success = $false + Error = "Invalid computer name. Must be 1-15 characters and use only letters, numbers, and hyphens." + } + return + } + + Write-LogHybrid "RenameComputer: renaming computer to '$newName'." Info OnBoard -LogToEvent + + try { + Rename-Computer -NewName $newName -Force -ErrorAction Stop + } catch { + Write-LogHybrid "RenameComputer: rename failed: $($_.Exception.Message)" Error OnBoard -LogToEvent + $Context.Response.StatusCode = 500 + Send-JSON $Context @{ + Success = $false + Error = $_.Exception.Message + } + return + } + + Write-LogHybrid "RenameComputer: rename complete, reboot required for new name to apply." Success OnBoard -LogToEvent + + Send-JSON $Context @{ + Success = $true + NewName = $newName + Note = "Rename successful. A reboot is required for the new name to take effect." + } + } catch { + Write-LogHybrid "Invoke-RenameComputer fatal error: $($_.Exception.Message)" Error OnBoard -LogToEvent + $Context.Response.StatusCode = 500 + Send-Text $Context "Internal error during computer rename." + } + } + + function Invoke-InstallDattoRMM { + param($Context) + + try { + if ($Context.Request.HttpMethod -ne 'POST') { + $Context.Response.StatusCode = 405 + Send-Text $Context 'Use POST' + return + } + + $body = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd() + $data = ConvertFrom-Json $body + + Install-DattoRMM ` + -UseWebhook ` + -WebhookPassword $Global:WebhookPassword ` + -SiteUID $data.UID ` + -SiteName $data.Name ` + -PushSiteVars:($data.checkedValues -contains 'inputVar') ` + -InstallRMM: ($data.checkedValues -contains 'rmm') ` + -SaveCopy: ($data.checkedValues -contains 'exe') + + Send-Text $Context "Triggered DattoRMM for $($data.Name)" + } + catch { + Write-LogHybrid "Invoke-InstallDattoRMM error: $($_.Exception.Message)" Error DattoRMM -LogToEvent + $Context.Response.StatusCode = 500 + Send-Text $Context "Internal server error during DattoRMM install." + } + } + + # App handlers + + function Invoke-InstallChrome { + param($Context) + try { + winget install --id=Google.Chrome --silent --accept-package-agreements --accept-source-agreements + Write-LogHybrid "Installed Google Chrome via winget" Success SVSApps -LogToEvent + Send-Text $Context "Chrome installed" + } catch { + Write-LogHybrid "Chrome install failed: $($_.Exception.Message)" Error SVSApps -LogToEvent + Send-Text $Context "ERROR: $($_.Exception.Message)" + } + } + + function Invoke-InstallAcrobat { + param($Context) + try { + winget install --id=Adobe.Acrobat.Reader.64-bit --silent --accept-package-agreements --accept-source-agreements + Write-LogHybrid "Installed Adobe Acrobat Reader (64-bit) via winget" Success SVSApps -LogToEvent + Send-Text $Context "Acrobat Reader installed" + } catch { + Write-LogHybrid "Acrobat install failed: $($_.Exception.Message)" Error SVSApps -LogToEvent + Send-Text $Context "ERROR: $($_.Exception.Message)" + } + } + + # Offboarding handlers + + function Invoke-UninstallCyberQP { + param($Context) + + try { + if (Get-Command Uninstall-CyberQP -ErrorAction Stop) { + Uninstall-CyberQP + Write-LogHybrid "CyberQP uninstalled" Success OffBoard -LogToEvent + if ($Context) { Send-Text $Context "CyberQP uninstalled." } + } else { + throw "Uninstall-CyberQP cmdlet not found in SVSMSP toolkit." + } + } + catch { + Write-LogHybrid "Error uninstalling CyberQP: $($_.Exception.Message)" Error OffBoard -LogToEvent + if ($Context) { Send-Text $Context "ERROR: $($_.Exception.Message)" } + } + } + + function Invoke-UninstallHelpDesk { + param($Context) + + try { + if (Get-Command Uninstall-HelpDesk -ErrorAction Stop) { + Uninstall-HelpDesk + Write-LogHybrid "SVS HelpDesk uninstalled" Success OffBoard -LogToEvent + if ($Context) { Send-Text $Context "SVS HelpDesk uninstalled." } + } else { + throw "Uninstall-HelpDesk cmdlet not found in SVSMSP toolkit." + } + } + catch { + Write-LogHybrid "Error uninstalling SVS HelpDesk: $($_.Exception.Message)" Error OffBoard -LogToEvent + if ($Context) { Send-Text $Context "ERROR: $($_.Exception.Message)" } + } + } + + function Invoke-UninstallThreatLocker { + param($Context) + + try { + if (Get-Command Uninstall-ThreatLocker -ErrorAction Stop) { + Uninstall-ThreatLocker + Write-LogHybrid "ThreatLocker uninstalled" Success OffBoard -LogToEvent + if ($Context) { Send-Text $Context "ThreatLocker uninstalled." } + } else { + throw "Uninstall-ThreatLocker cmdlet not found in SVSMSP toolkit." + } + } + catch { + Write-LogHybrid "Error uninstalling ThreatLocker: $($_.Exception.Message)" Error OffBoard -LogToEvent + if ($Context) { Send-Text $Context "ERROR: $($_.Exception.Message)" } + } + } + + function Invoke-UninstallRocketCyber { + param($Context) + + try { + if (Get-Command Uninstall-RocketCyber -ErrorAction Stop) { + Uninstall-RocketCyber + Write-LogHybrid "RocketCyber uninstalled" Success OffBoard -LogToEvent + if ($Context) { Send-Text $Context "RocketCyber uninstalled." } + } else { + throw "Uninstall-RocketCyber cmdlet not found in SVSMSP toolkit." + } + } + catch { + Write-LogHybrid "Error uninstalling RocketCyber: $($_.Exception.Message)" Error OffBoard -LogToEvent + if ($Context) { Send-Text $Context "ERROR: $($_.Exception.Message)" } + } + } + + function Invoke-CleanupSVSMSP { + param($Context) + + try { + if (Get-Command Install-SVSMSP -ErrorAction Stop) { + Install-SVSMSP -Cleanup + + Write-LogHybrid "SVSMSP toolkit cleanup completed (module, repo, registry)." Success OffBoard -LogToEvent + if ($Context) { Send-Text $Context "SVSMSP toolkit cleanup completed." } + } else { + throw "Install-SVSMSP function not found in current session." + } + } + catch { + Write-LogHybrid "Error cleaning up SVSMSP toolkit: $($_.Exception.Message)" Error OffBoard -LogToEvent + if ($Context) { Send-Text $Context "ERROR: $($_.Exception.Message)" } + } + } + + # Printer handlers and core + + function Get-SamyDriverRootFolder { + [CmdletBinding()] + param() + + $root = Join-Path $env:ProgramData 'SVS\Samy\Drivers' + + if (-not (Test-Path $root)) { + try { + New-Item -Path $root -ItemType Directory -Force | Out-Null + Write-LogHybrid "Created driver root folder '$root'." Info Printers -LogToEvent + } catch { + Write-LogHybrid "Failed to create driver root folder '$root': $($_.Exception.Message)" Warning Printers -LogToEvent + } + } + + return $root + } + + function Get-SamyDriverFolderForProfile { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Profile + ) + + $root = Get-SamyDriverRootFolder + + if ($Profile.PSObject.Properties.Name -contains 'DriverFolderName' -and $Profile.DriverFolderName) { + $folderName = $Profile.DriverFolderName + } else { + $folderName = "$($Profile.ClientCode)_$($Profile.ProfileName)" + } + + $dest = Join-Path $root $folderName + + if (-not (Test-Path $dest)) { + try { + New-Item -Path $dest -ItemType Directory -Force | Out-Null + Write-LogHybrid "Created driver folder '$dest'." Info Printers -LogToEvent + } catch { + Write-LogHybrid "Failed to create driver folder '$dest': $($_.Exception.Message)" Warning Printers -LogToEvent + } + } + + return $dest + } + + function Get-SamyDriverPackageUrl { + [CmdletBinding()] + param( + [Parameter(Mandatory)][pscustomobject]$Profile + ) + + if ($Profile.PSObject.Properties.Name -contains 'DriverPackageUrl' -and $Profile.DriverPackageUrl) { + return $Profile.DriverPackageUrl + } + + if ($Profile.PSObject.Properties.Name -contains 'DriverPackagePath' -and $Profile.DriverPackagePath) { + return "$Script:SamyRepoBase/$Script:SamyBranch/$($Profile.DriverPackagePath)?raw=1" + } + + return $null + } + + function Get-SamyClientListFromServer { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Uri, + [Parameter(Mandatory)][string]$Password + ) + + try { + Write-LogHybrid "Calling client list service at $Uri" Info Printers -LogToEvent + + $headers = @{ + SAMYPW = $Password + } + + $resp = Invoke-RestMethod -Uri $Uri ` + -Method Get ` + -Headers $headers ` + -ContentType 'application/json' ` + -ErrorAction Stop + + if (-not $resp) { + Write-LogHybrid "Client list service returned no data." Warning Printers -LogToEvent + return @() + } + + if ($resp -is [System.Collections.IEnumerable] -and -not ($resp -is [string])) { + return @($resp) + } else { + return ,$resp + } + } + catch { + Write-LogHybrid ("Get-SamyClientListFromServer failed for {0}: {1}" -f $Uri, $_.Exception.Message) Error Printers -LogToEvent + return @() + } + } + + function Invoke-GetPrinters { + param($Context) + + try { + if ($Context.Request.HttpMethod -ne 'POST') { + $Context.Response.StatusCode = 405 + Send-Text $Context 'Use POST' + return + } + + $rawBody = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd() + if (-not $rawBody) { + $Context.Response.StatusCode = 400 + Send-Text $Context 'Missing request body.' + return + } + + try { + $body = $rawBody | ConvertFrom-Json + } catch { + $Context.Response.StatusCode = 400 + Send-Text $Context 'Invalid JSON body.' + return + } + + $password = $body.password + if (-not $password) { + $Context.Response.StatusCode = 400 + Send-Text $Context 'Password is required.' + return + } + + $uri = 'https://bananas.svstools.ca/getprinters' + Write-LogHybrid "Fetching printers from $uri" Info Printers -LogToEvent + + $printers = Get-SamyClientListFromServer -Uri $uri -Password $password + + if ($null -eq $printers) { + Write-LogHybrid "Get-SamyClientListFromServer returned `$null; sending empty JSON array." Warning Printers -LogToEvent + $printers = @() + } + + try { + Update-SamyPrinterConfig -PrinterProfiles $printers -SkipIfEmpty + } + catch { + Write-LogHybrid "Update-SamyPrinterConfig failed: $($_.Exception.Message)" Warning Printers -LogToEvent + } + + Send-JSON $Context $printers + } + catch { + Write-LogHybrid "Invoke-GetPrinters error: $($_.Exception.Message)" Error Printers -LogToEvent + $Context.Response.StatusCode = 500 + Send-Text $Context "Internal server error fetching printers." + } + } + + function Invoke-InstallPrinters { + param($Context) + + try { + if ($Context.Request.HttpMethod -ne 'POST') { + $Context.Response.StatusCode = 405 + Send-Text $Context 'Use POST' + return + } + + $rawBody = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd() + if (-not $rawBody) { + $Context.Response.StatusCode = 400 + Send-Text $Context 'Missing request body.' + return + } + + try { + $body = $rawBody | ConvertFrom-Json + } catch { + $Context.Response.StatusCode = 400 + Send-Text $Context 'Invalid JSON body.' + return + } + + $printers = $body.printers + if (-not $printers -or $printers.Count -eq 0) { + $Context.Response.StatusCode = 400 + Send-Text $Context 'No printers specified.' + return + } + + Write-LogHybrid "Received request to install $($printers.Count) printers." Info Printers -LogToEvent + + $successCount = 0 + $failures = @() + + foreach ($p in $printers) { + $clientCode = $p.ClientCode + $profileName = $p.ProfileName + $setDefault = $false + + if ($p.PSObject.Properties.Name -contains 'SetAsDefault' -and $p.SetAsDefault) { + $setDefault = $true + } + + if (-not $clientCode -or -not $profileName) { + $msg = "Skipping printer entry because ClientCode or ProfileName is missing." + Write-LogHybrid $msg Warning Printers -LogToEvent + $failures += $msg + continue + } + + $summary = "ClientCode=$clientCode ProfileName=$profileName DisplayName=$($p.DisplayName) Location=$($p.Location) SetAsDefault=$setDefault" + Write-LogHybrid "Installing printer ($summary)" Info Printers -LogToEvent + + try { + Invoke-SamyPrinterInstall ` + -ClientCode $clientCode ` + -ProfileName $profileName ` + -SetAsDefault:$setDefault ` + -WhatIf + + $successCount++ + } + catch { + $errMsg = "Failed to install printer ($summary): $($_.Exception.Message)" + Write-LogHybrid $errMsg Error Printers -LogToEvent + $failures += $errMsg + } + } + + $result = @{ + SuccessCount = $successCount + FailureCount = $failures.Count + Failures = $failures + Message = "Printer install (WHATIF) processed. Check SAMY logs for detail." + } + + Send-JSON $Context $result + } + catch { + Write-LogHybrid "Invoke-InstallPrinters error: $($_.Exception.Message)" Error Printers -LogToEvent + $Context.Response.StatusCode = 500 + Send-Text $Context "Internal server error installing printers." + } + } + + function Get-SamyPrinterLocalConfigPath { + [CmdletBinding()] + param() + + $configDir = Join-Path $env:ProgramData 'SVS\Samy\Printers' + + if (-not (Test-Path $configDir)) { + try { + New-Item -Path $configDir -ItemType Directory -Force | Out-Null + Write-LogHybrid "Created printer config folder at '$configDir'." Info Printers -LogToEvent + } + catch { + Write-LogHybrid "Failed to create printer config folder '$configDir': $($_.Exception.Message)" Warning Printers -LogToEvent + } + } + + return (Join-Path $configDir 'printers.json') + } + + function Get-SamyPrinterConfigFromFile { + [CmdletBinding()] + param() + + $path = Get-SamyPrinterLocalConfigPath + + if (-not (Test-Path $path)) { + throw "Local printer config file not found at '$path'. Create or update printers.json first." + } + + $json = Get-Content -Path $path -Raw -ErrorAction Stop + $profiles = $json | ConvertFrom-Json + + if (-not $profiles) { + throw "Printer config file '$path' is empty or invalid JSON." + } + + return $profiles + } + + $Script:Samy_PrinterProfiles = $null + + function Get-SamyPrinterProfiles { + [CmdletBinding()] + param( + [string]$ClientCode + ) + + if (-not $Script:Samy_PrinterProfiles) { + $Script:Samy_PrinterProfiles = Get-SamyPrinterConfigFromFile + } + + $result = $Script:Samy_PrinterProfiles + + if ($PSBoundParameters.ContainsKey('ClientCode') -and $ClientCode) { + $result = $result | Where-Object { $_.ClientCode -eq $ClientCode } + } + + return $result + } + + function Get-SamyPrinterProfile { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ClientCode, + [Parameter(Mandatory)][string]$ProfileName + ) + + $profiles = Get-SamyPrinterProfiles -ClientCode $ClientCode + $match = $profiles | Where-Object { $_.ProfileName -eq $ProfileName } + + if (-not $match) { + throw "No printer profile found for ClientCode '$ClientCode' and ProfileName '$ProfileName'." + } + + if ($match.Count -gt 1) { + throw "Multiple printer profiles found for ClientCode '$ClientCode' and ProfileName '$ProfileName'. De-duplicate in printers.json." + } + + return $match + } + + function Ensure-SamyPrinterDriver { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject]$Profile + ) + + $driverName = $Profile.DriverName + if (-not $driverName) { + throw "Profile '$($Profile.ProfileName)' has no DriverName defined in printer config." + } + + $existingDriver = Get-PrinterDriver -Name $driverName -ErrorAction SilentlyContinue + if ($existingDriver) { + Write-LogHybrid "Printer driver '$driverName' already installed." Info Printers -LogToEvent + return + } + + Write-LogHybrid "Printer driver '$driverName' not found. Preparing to install." Info Printers -LogToEvent + + $localDriverRoot = Get-SamyDriverFolderForProfile -Profile $Profile + + $infPath = $null + if ($Profile.PSObject.Properties.Name -contains 'DriverInfPath' -and $Profile.DriverInfPath) { + if (Test-Path $Profile.DriverInfPath) { + $infPath = $Profile.DriverInfPath + Write-LogHybrid "Using existing INF path '$infPath' for driver '$driverName'." Info Printers -LogToEvent + } else { + Write-LogHybrid "Configured DriverInfPath '$($Profile.DriverInfPath)' does not exist, will try repo download." Warning Printers -LogToEvent + } + } + + $packageDownloaded = $false + + if ($Profile.PSObject.Properties.Name -contains 'DriverPackagePath' -and $Profile.DriverPackagePath) { + $driverPackageUrl = "$Script:SamyRepoBase/$Script:SamyBranch/$($Profile.DriverPackagePath)?raw=1" + $localZip = Join-Path $localDriverRoot "package.zip" + + Write-LogHybrid "Attempting to download driver package from $driverPackageUrl." Info Printers -LogToEvent + + try { + Invoke-WebRequest -Uri $driverPackageUrl -OutFile $localZip -UseBasicParsing -ErrorAction Stop + Write-LogHybrid "Downloaded driver package from $driverPackageUrl to $localZip." Success Printers -LogToEvent + $packageDownloaded = $true + } + catch [System.Net.WebException] { + $response = $_.Exception.Response + $statusCode = $null + if ($response -and $response.StatusCode) { + $statusCode = [int]$response.StatusCode + } + + if ($statusCode -eq 404) { + Write-LogHybrid "Driver package not found at $driverPackageUrl (404). Falling back to INF-only install for '$($Profile.DisplayName)'." Warning Printers -LogToEvent + } + else { + Write-LogHybrid "Driver package download failed ($statusCode) from $driverPackageUrl: $($_.Exception.Message)" Error Printers -LogToEvent + throw "Failed to download driver package from $driverPackageUrl: $($_.Exception.Message)" + } + } + catch { + Write-LogHybrid "Driver package download failed from $driverPackageUrl: $($_.Exception.Message)" Error Printers -LogToEvent + throw "Failed to download driver package from $driverPackageUrl: $($_.Exception.Message)" + } + } + else { + Write-LogHybrid "No DriverPackagePath defined for '$($Profile.DisplayName)'; will rely on local INF." Info Printers -LogToEvent + } + + if ($packageDownloaded) { + try { + Expand-Archive -Path $localZip -DestinationPath $localDriverRoot -Force + Write-LogHybrid "Expanded driver package to '$localDriverRoot'." Info Printers -LogToEvent + } + catch { + Write-LogHybrid "Failed to expand driver package '$localZip': $($_.Exception.Message)" Error Printers -LogToEvent + throw "Failed to expand driver package '$localZip': $($_.Exception.Message)" + } + + if (-not $infPath) { + if ($Profile.PSObject.Properties.Name -contains 'DriverInfName' -and $Profile.DriverInfName) { + + $candidateInf = Join-Path $localDriverRoot $Profile.DriverInfName + if (Test-Path $candidateInf) { + $infPath = $candidateInf + Write-LogHybrid "Resolved INF from package as '$infPath' using DriverInfName '$($Profile.DriverInfName)' at root." Info Printers -LogToEvent + } + else { + Write-LogHybrid "Expected INF '$candidateInf' not found at root; searching recursively..." Warning Printers -LogToEvent + + $found = Get-ChildItem -Path $localDriverRoot -Recurse -Filter $Profile.DriverInfName -File -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if ($found) { + $infPath = $found.FullName + Write-LogHybrid "Resolved INF from package as '$infPath' (found by recursive search for '$($Profile.DriverInfName)')." Info Printers -LogToEvent + } + else { + Write-LogHybrid "Could not find any '$($Profile.DriverInfName)' under '$localDriverRoot' after expanding package." Error Printers -LogToEvent + } + } + } + else { + Write-LogHybrid "DriverInfName not defined for profile '$($Profile.ProfileName)'; cannot auto-resolve INF from expanded package." Warning Printers -LogToEvent + } + } + } + + if (-not $infPath -or -not (Test-Path $infPath)) { + throw "Driver '$driverName' is not installed and no valid DriverInfPath or usable driver package is available for profile '$($Profile.ProfileName)'." + } + + Write-LogHybrid "Installing printer driver '$driverName' from '$infPath'." Info Printers -LogToEvent + + $pnputilCmd = "pnputil.exe /add-driver `"$infPath`" /install" + Write-LogHybrid "Running: $pnputilCmd" Info Printers -LogToEvent + + $pnputilOutput = & pnputil.exe /add-driver "$infPath" /install 2>&1 + $exitCode = $LASTEXITCODE + + Write-LogHybrid "pnputil exit code: $exitCode. Output:`n$pnputilOutput" Info Printers -LogToEvent + + if ($exitCode -ne 0) { + throw "pnputil failed with exit code $exitCode installing '$driverName' from '$infPath'." + } + + try { + Write-LogHybrid "Calling Add-PrinterDriver -Name '$driverName'." Info Printers -LogToEvent + Add-PrinterDriver -Name $driverName -ErrorAction Stop + } + catch { + Write-LogHybrid "Add-PrinterDriver failed for '$driverName' using '$infPath': $($_.Exception.Message)" Error Printers -LogToEvent + throw "Add-PrinterDriver failed for '$driverName': $($_.Exception.Message)" + } + + Start-Sleep -Seconds 2 + + $existingDriver = Get-PrinterDriver -Name $driverName -ErrorAction SilentlyContinue + + if (-not $existingDriver) { + $sharpNames = (Get-PrinterDriver -ErrorAction SilentlyContinue | + Where-Object Name -like 'SHARP*' | + Select-Object -ExpandProperty Name) -join ', ' + + if (-not $sharpNames) { $sharpNames = '(none)' } + + Write-LogHybrid "After pnputil/Add-PrinterDriver, driver '$driverName' not found. Existing SHARP drivers: $sharpNames" Warning Printers -LogToEvent + throw "Failed to find printer driver '$driverName' after Add-PrinterDriver." + } + + Write-LogHybrid "Printer driver '$driverName' installed and detected successfully." Success Printers -LogToEvent + } + + function Install-SamyTcpIpPrinter { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject]$Profile, + + [switch]$SetAsDefault + ) + + $portName = $Profile.Address + $printerName = $Profile.DisplayName + + if (-not $portName) { + throw "TCP/IP printer profile '$($Profile.ProfileName)' is missing Address in printer config." + } + + if (-not (Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue)) { + Write-Verbose "Creating TCP/IP port '$portName'." + Add-PrinterPort -Name $portName -PrinterHostAddress $Profile.Address + } + else { + Write-Verbose "TCP/IP port '$portName' already exists." + } + + $existingPrinter = Get-Printer -Name $printerName -ErrorAction SilentlyContinue + if ($existingPrinter) { + Write-Verbose "Printer '$printerName' already exists. Skipping creation." + } + else { + Write-Verbose "Creating printer '$printerName' on port '$portName' using driver '$($Profile.DriverName)'." + Add-Printer -Name $printerName -PortName $portName -DriverName $Profile.DriverName + } + + if ($SetAsDefault -or $Profile.IsDefault) { + Write-Verbose "Setting '$printerName' as default printer." + (New-Object -ComObject WScript.Network).SetDefaultPrinter($printerName) + } + } + + function Install-SamySharedPrinter { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject]$Profile, + + [switch]$SetAsDefault + ) + + if (-not $Profile.PrintServer -or -not $Profile.ShareName) { + throw "Shared printer profile '$($Profile.ProfileName)' is missing PrintServer or ShareName in printer config." + } + + $connectionName = "\\$($Profile.PrintServer)\$($Profile.ShareName)" + + $existing = Get-Printer -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -eq $Profile.DisplayName -or + $_.ShareName -eq $Profile.ShareName + } + + if ($existing) { + Write-Verbose "Shared printer '$($Profile.DisplayName)' already connected as '$($existing.Name)'." + $printerName = $existing.Name + } + else { + Write-Verbose "Adding shared printer connection '$connectionName'." + Add-Printer -ConnectionName $connectionName + + $printerName = (Get-Printer | + Where-Object { $_.Name -like "*$($Profile.ShareName)*" } | + Select-Object -First 1 + ).Name + } + + if ($SetAsDefault -or $Profile.IsDefault) { + Write-Verbose "Setting '$printerName' as default printer." + (New-Object -ComObject WScript.Network).SetDefaultPrinter($printerName) + } + } + + function Invoke-SamyPrinterInstall { + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory)] + [string]$ClientCode, + + [Parameter(Mandatory)] + [string]$ProfileName, + + [switch]$SetAsDefault + ) + + try { + $profile = Get-SamyPrinterProfile -ClientCode $ClientCode -ProfileName $ProfileName + $targetName = $profile.DisplayName + + if ($PSCmdlet.ShouldProcess($targetName, "Install printer")) { + + Write-LogHybrid "Installing printer profile '$($profile.ProfileName)' for client '$ClientCode' (WhatIf=$WhatIfPreference)." Info Printers -LogToEvent + + Ensure-SamyPrinterDriver -Profile $profile + + switch ($profile.Type) { + 'TcpIp' { + Install-SamyTcpIpPrinter -Profile $profile -SetAsDefault:$SetAsDefault + } + 'Shared' { + Install-SamySharedPrinter -Profile $profile -SetAsDefault:$SetAsDefault + } + default { + throw "Unsupported printer Type '$($profile.Type)' in profile '$($profile.ProfileName)'. Use 'TcpIp' or 'Shared'." + } + } + + Write-LogHybrid "Installed printer '$($profile.DisplayName)' (Client=$ClientCode Profile=$ProfileName)." Info Printers -LogToEvent + } + } + catch { + Write-LogHybrid ( + "Printer install failed for Client={0} Profile={1}: {2}" -f $ClientCode, $ProfileName, $_.Exception.Message + ) Error Printers -LogToEvent + throw + } + } + + function Update-SamyPrinterConfig { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$PrinterProfiles, + + [switch]$SkipIfEmpty + ) + + $path = Get-SamyPrinterLocalConfigPath + + $profilesArray = @($PrinterProfiles) + + if ($SkipIfEmpty -and ($null -eq $PrinterProfiles -or $profilesArray.Count -eq 0)) { + Write-LogHybrid "Update-SamyPrinterConfig: no printer profiles returned; keeping existing printers.json." Warning Printers -LogToEvent + return + } + + if ($profilesArray.Count -eq 0) { + Write-LogHybrid "Update-SamyPrinterConfig: zero profiles; writing empty printers.json." Warning Printers -LogToEvent + } + + try { + $profilesArray | ConvertTo-Json -Depth 5 | Set-Content -Path $path -Encoding UTF8 + Write-LogHybrid "Saved $($profilesArray.Count) printer profiles to '$path'." Success Printers -LogToEvent + + $Script:Samy_PrinterProfiles = $null + } + catch { + Write-LogHybrid "Failed to write printers.json to '$path': $($_.Exception.Message)" Error Printers -LogToEvent + } + } + + # Install-DattoRMM core function + + function Install-DattoRMM { + <# + .SYNOPSIS + Installs/configures the Datto RMM agent and handles sites/variables. + #> + [CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='Medium')] + param ( + [switch]$UseWebhook, + [string]$WebhookPassword, + [string]$WebhookUrl = $Global:DattoWebhookUrl, + [string]$ApiUrl, + [string]$ApiKey, + [string]$ApiSecretKey, + [switch]$FetchSites, + [switch]$SaveSitesList, + [string]$OutputFile = 'datto_sites.csv', + [switch]$PushSiteVars, + [switch]$InstallRMM, + [switch]$SaveCopy, + [string]$SiteUID, + [string]$SiteName + ) + + if ($SaveSitesList -and -not $FetchSites) { + Write-LogHybrid "-SaveSitesList requires -FetchSites." Error DattoRMM -LogToEvent + return + } + + if ($UseWebhook) { + if (-not $WebhookPassword) { + Write-LogHybrid "Webhook password missing." Error DattoRMM -LogToEvent + return + } + try { + $resp = Invoke-RestMethod -Uri $WebhookUrl ` + -Headers @{ SAMYPW = $WebhookPassword } ` + -Method GET + $ApiUrl = $resp.ApiUrl + $ApiKey = $resp.ApiKey + $ApiSecretKey = $resp.ApiSecretKey + Write-LogHybrid "Webhook credentials fetched." Success DattoRMM -LogToEvent + } catch { + Write-LogHybrid "Failed to fetch webhook credentials: $($_.Exception.Message)" Error DattoRMM -LogToEvent + return + } + } + + if (-not $ApiUrl -or -not $ApiKey -or -not $ApiSecretKey) { + Write-LogHybrid "Missing required API parameters." Error DattoRMM -LogToEvent + return + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + try { + $publicCred = New-Object System.Management.Automation.PSCredential( + 'public-client', (ConvertTo-SecureString 'public' -AsPlainText -Force) + ) + $tokenResp = Invoke-RestMethod -Uri "$ApiUrl/auth/oauth/token" ` + -Credential $publicCred ` + -Method Post ` + -ContentType 'application/x-www-form-urlencoded' ` + -Body "grant_type=password&username=$ApiKey&password=$ApiSecretKey" + $token = $tokenResp.access_token + Write-LogHybrid "OAuth token acquired." Success DattoRMM -LogToEvent + } catch { + Write-LogHybrid "OAuth token fetch failed: $($_.Exception.Message)" Error DattoRMM -LogToEvent + return + } + + $headers = @{ Authorization = "Bearer $token" } + + if ($FetchSites) { + try { + $sitesResp = Invoke-RestMethod -Uri "$ApiUrl/api/v2/account/sites" -Method Get -Headers $headers + $siteList = $sitesResp.sites | Sort-Object name | ForEach-Object { + [PSCustomObject]@{ Name = $_.name; UID = $_.uid } + } + Write-LogHybrid "Fetched $($siteList.Count) sites." Success DattoRMM -LogToEvent + + if ($SaveSitesList) { + $desktop = [Environment]::GetFolderPath('Desktop') + $path = Join-Path $desktop $OutputFile + $ext = [IO.Path]::GetExtension($OutputFile).ToLower() + if ($ext -eq '.json') { + $siteList | ConvertTo-Json -Depth 3 | Out-File -FilePath $path -Encoding UTF8 + } else { + $siteList | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8 + } + Write-LogHybrid "Wrote $($siteList.Count) sites to $path" Success DattoRMM -LogToEvent + } + + return $siteList + } catch { + Write-LogHybrid "Failed to fetch sites: $($_.Exception.Message)" Error DattoRMM -LogToEvent + return @() + } + } + + if ($PushSiteVars) { + try { + $varsResp = Invoke-RestMethod -Uri "$ApiUrl/api/v2/site/$SiteUID/variables" -Method Get -Headers $headers + Write-LogHybrid "Fetched variables for '$SiteName'." Success DattoRMM -LogToEvent + } catch { + Write-LogHybrid "Variable fetch failed: $($_.Exception.Message)" Error DattoRMM -LogToEvent + } + $regPath = "HKLM:\Software\SVS\Deployment" + foreach ($v in $varsResp.variables) { + try { + if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } + New-ItemProperty -Path $regPath -Name $v.name -Value $v.value -PropertyType String -Force | Out-Null + Write-LogHybrid "Wrote '$($v.name)' to registry." Success DattoRMM -LogToEvent + } catch { + Write-LogHybrid "Failed to write '$($v.name)': $($_.Exception.Message)" Error DattoRMM -LogToEvent + } + } + } + + if ($InstallRMM) { + if ($PSCmdlet.ShouldProcess("Site '$SiteName'", "Install RMM agent")) { + try { + $dlUrl = "https://zinfandel.centrastage.net/csm/profile/downloadAgent/$SiteUID" + $tmp = "$env:TEMP\AgentInstall.exe" + Invoke-WebRequest -Uri $dlUrl -OutFile $tmp -UseBasicParsing + Write-LogHybrid "Downloaded agent to $tmp." Info DattoRMM -LogToEvent + Start-Process -FilePath $tmp -NoNewWindow + Write-LogHybrid "RMM agent installer launched." Success DattoRMM -LogToEvent + } catch { + Write-LogHybrid "Agent install failed: $($_.Exception.Message)" Error DattoRMM -LogToEvent + } + } + } + + if ($SaveCopy) { + try { + $dlUrl = "https://zinfandel.centrastage.net/csm/profile/downloadAgent/$SiteUID" + $path = "C:\Temp\AgentInstall.exe" + if (-not (Test-Path 'C:\Temp')) { New-Item -Path 'C:\Temp' -ItemType Directory | Out-Null } + Invoke-WebRequest -Uri $dlUrl -OutFile $path -UseBasicParsing + Write-LogHybrid "Saved installer copy to $path." Info DattoRMM -LogToEvent + } catch { + Write-LogHybrid "Save-copy failed: $($_.Exception.Message)" Error DattoRMM -LogToEvent + } + } + + if (-not ($PushSiteVars -or $InstallRMM -or $SaveCopy)) { + Write-LogHybrid "No action specified. Use -FetchSites, -SaveSitesList, -PushSiteVars, -InstallRMM, or -SaveCopy." Warning DattoRMM -LogToEvent + } + } + + # Dispatch-Request + + function Dispatch-Request { + param($Context) + + $path = $Context.Request.Url.AbsolutePath.TrimStart('/') + + if ($path -eq 'quit') { + Write-LogHybrid "Shutdown requested" Info Server -LogToEvent + Send-Text $Context "Server shutting down." + $Global:Listener.Stop() + return + } + + if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'tasksCompleted') { + Invoke-TasksCompleted $Context + return + } + + if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'getpw') { + Invoke-FetchSites $Context + return + } + + if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'renameComputer') { + Invoke-RenameComputer $Context + return + } + + if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'getprinters') { + Invoke-GetPrinters $Context + return + } + + if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'installprinters') { + Invoke-InstallPrinters $Context + return + } + + if ($path -in @('', 'onboard', 'offboard', 'tweaks', 'SVSApps', 'devices')) { + $page = if ($path -eq '') { 'onboard' } else { $path } + $html = Get-UIHtml -Page $page + Send-HTML $Context $html + return + } + + $task = $Global:SamyTasks | Where-Object Name -EQ $path + if ($task) { + & $task.HandlerFn $Context + return + } + + $Context.Response.StatusCode = 404 + Send-Text $Context '404 - Not Found' + } + + # MAIN LOGIC (Toolkit vs DattoFetch vs DattoInstall vs UI) + + Write-LogHybrid "Invoke-ScriptAutomationMonkey starting (ParameterSet=$($PSCmdlet.ParameterSetName))" Info Bootstrap -LogToEvent + + switch ($PSCmdlet.ParameterSetName) { + + 'Toolkit' { + Write-LogHybrid "Running in Toolkit mode (-SilentInstall)." Info SVSModule -LogToEvent + + Install-SVSMSP -InstallToolkit + + Write-LogHybrid "Toolkit mode completed." Success SVSModule -LogToEvent + return + } + + 'Cleanup' { + Write-LogHybrid "Running in Cleanup mode (-Cleanup)." Info SVSModule -LogToEvent + + Install-SVSMSP -Cleanup + + Write-LogHybrid "Cleanup mode completed." Success SVSModule -LogToEvent + return + } + + 'Offboard' { + Write-LogHybrid "Running in headless Offboard mode (-Offboard)." Info OffBoard -LogToEvent + + try { Invoke-UninstallCyberQP -Context $null } catch { Write-LogHybrid "Headless offboard: CyberQP uninstall failed: $($_.Exception.Message)" Error OffBoard -LogToEvent } + try { Invoke-UninstallHelpDesk -Context $null } catch { Write-LogHybrid "Headless offboard: HelpDesk uninstall failed: $($_.Exception.Message)" Error OffBoard -LogToEvent } + try { Invoke-UninstallThreatLocker -Context $null } catch { Write-LogHybrid "Headless offboard: ThreatLocker uninstall failed: $($_.Exception.Message)" Error OffBoard -LogToEvent } + try { Invoke-UninstallRocketCyber -Context $null } catch { Write-LogHybrid "Headless offboard: RocketCyber uninstall failed: $($_.Exception.Message)" Error OffBoard -LogToEvent } + try { Invoke-CleanupSVSMSP -Context $null } catch { Write-LogHybrid "Headless offboard: SVSMSP cleanup failed: $($_.Exception.Message)" Error OffBoard -LogToEvent } + + Write-LogHybrid "Headless Offboard mode completed." Success OffBoard -LogToEvent + return + } + + 'DattoFetch' { + Write-LogHybrid "Running in DattoFetch mode (fetch sites)." Info DattoRMM -LogToEvent + + $sites = Install-DattoRMM ` + -UseWebhook ` + -WebhookPassword $WebhookPassword ` + -FetchSites ` + -SaveSitesList:$SaveSitesList ` + -OutputFile $OutputFile + + if ($sites) { + Write-LogHybrid "DattoFetch returned $($sites.Count) sites." Success DattoRMM -LogToEvent + + try { + $sites | Sort-Object Name | Format-Table Name, UID -AutoSize + } catch { + Write-LogHybrid "Failed to render Datto site table to console: $($_.Exception.Message)" Warning DattoRMM -LogToEvent + } + } else { + Write-LogHybrid "DattoFetch returned no sites." Warning DattoRMM -LogToEvent + } + + return + } + + 'DattoInstall' { + Write-LogHybrid "Running in DattoInstall mode for site '$SiteName' ($SiteUID)." Info DattoRMM -LogToEvent + + Install-DattoRMM ` + -UseWebhook ` + -WebhookPassword $WebhookPassword ` + -SiteUID $SiteUID ` + -SiteName $SiteName ` + -PushSiteVars:$PushSiteVars ` + -InstallRMM:$InstallRMM ` + -SaveCopy:$SaveCopy + + Write-LogHybrid "DattoInstall mode completed for site '$SiteName'." Success DattoRMM -LogToEvent + return + } + + default { + Write-LogHybrid "Starting Script Automation Monkey UI on port $Port." Info UI -LogToEvent + + try { + Start-Process "http://localhost:$Port/" | Out-Null + } catch { + Write-LogHybrid "Failed to launch browser: $($_.Exception.Message)" Warning UI -LogToEvent + } + + Start-Server + + Write-LogHybrid "Script Automation Monkey UI stopped (listener exited)." Info UI -LogToEvent + } + } + +} # end function Invoke-ScriptAutomationMonkey + +if ($MyInvocation.InvocationName -ne '.') { + Invoke-ScriptAutomationMonkey @args +}