<# .SYNOPSIS Script Automation Monkey (SAMY) ... .NOTES Full documentation: https://git.svstools.ca/.../docs/SAMY.help.md #> #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 # Build token list (NO manual quoting) $argList = foreach ($a in $args) { [string]$a } if ($PSCommandPath) { powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$PSCommandPath" @argList } else { $argString = ($argList | ForEach-Object { '"' + ($_ -replace '"','`"') + '"' }) -join ' ' powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "& { iwr 'https://samybeta.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 { # ───────────────────────────────────────────────────────────────────────── # PARAMETERS + GLOBAL VARIABLES # ───────────────────────────────────────────────────────────────────────── [CmdletBinding( DefaultParameterSetName='UI', SupportsShouldProcess=$true, ConfirmImpact= 'Medium' )] #region Parameter Definitions 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 # ─── DattoFetch & DattoInstall share the webhook creds ───────────── [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 ) #endregion Parameter Definitions #region 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' if (Get-Command Set-SvsPrinterRepoConfig -ErrorAction SilentlyContinue) { Set-SvsPrinterRepoConfig -RepoBase $Script:SamyRepoBase -Branch $Script:SamyBranch } # 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?raw=1" $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" $Script:SamyHtmlUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.html?raw=1" $Script:SamyHintText = "" $Script:SamyTasksUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.tasks.json?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() } #endregion global variables #region SVS Module function Initialize-NuGetProvider { [CmdletBinding()] param() #region — guarantee NuGet provider is present without prompting # ─── Silent defaults ─── [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $ProgressPreference = 'SilentlyContinue' $ConfirmPreference = 'None' # ─── Pre-create folder if running as SYSTEM (avoids NuGet install bug) ─── $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 } #endregion — guarantee NuGet provider is present without prompting } 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 no module was found, just warn and continue 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 } } # Finally, remove it from the current session if loaded 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 # 1) Make sure the source exists 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 } # 2) If it's already correct, bail out 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 # 3) Delete and recreate the source bound to the desired log 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 # After module install, repair Event Log binding for legacy systems 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 } #endregion SVS Module #region Write-Log # Fallback logger used when the SVSMSP module (and its Write-Log) is not available. # Mirrors the behaviour of the toolkit Write-Log (v1.5), including: # - Default EventLog: "SVSMSP Events" (out of Application log) # - Default EventSource: "SVSMSP_Module" # - Level-based Event IDs and console colors # - Global in-memory log cache # - One-time Event Log/source initialization with optional auto-elevation function Write-LogHelper { <# .SYNOPSIS Standardized logging utility with console/file output and Windows Event Log support, including one-time event source initialization and optional auto-elevated creation of a custom log/source. (Fallback implementation for ScriptAutomationMonkey.) .DESCRIPTION Mirrors the SVSMSP toolkit Write-Log so that Write-LogHybrid can safely fall back when the module isn't loaded. .NOTES Default EventLog : SVSMSP Events Default Source : SVSMSP_Module #> [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", # Custom log name so you get your own node under "Applications and Services Logs" [string]$EventLog = "SVSMSP Events", [int]$CustomEventID, [string]$LogFile, [switch]$PassThru ) # ---------- Event ID / console color ---------- $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 # ---------- In-memory cache ---------- 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) # ---------- Optional file output ---------- 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 } } # ---------- Windows Event Log handling with one-time init + optional auto-elevate ---------- if ($LogToEvent) { # Per-run cache for (LogName|Source) init state if (-not $Global:EventSourceInitState) { $Global:EventSourceInitState = @{} } $EntryType = switch ($Level) { "Info" { "Information" } "Warning" { "Warning" } "Error" { "Error" } "Success" { "Information" } # treat success as info in Event Log default { "Information" } } $sourceKey = "$EventLog|$EventSource" if (-not $Global:EventSourceInitState.ContainsKey($sourceKey) -or -not $Global:EventSourceInitState[$sourceKey]) { try { # Only bother if the source doesn't already exist if (-not [System.Diagnostics.EventLog]::SourceExists($EventSource)) { # Check if current token is admin $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) { # Elevated already: create log/source directly New-EventLog -LogName $EventLog -Source $EventSource -ErrorAction Stop } else { # Not elevated: run a one-off helper as admin to create log/source $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 { # This will trigger UAC prompt in interactive sessions $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 } } } # Re-check after creation attempt 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 } } # Only write if initialization succeeded 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 } } # ───────────────────────────────────────────────────────────────────────── # WRITE-LOG HYBRID # Uses module Write-Log if present; otherwise falls back to Write-LogHelper. # Defaults aligned with toolkit: # EventSource = "SVSMSP_Module" # EventLog = "SVSMSP Events" # ───────────────────────────────────────────────────────────────────────── 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" # Build the common parameter set for forwarding into Write-Log / Write-LogHelper $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')) { # 1) print to console with the override color Write-Host $formatted -ForegroundColor $ForegroundColorOverride # 2) then forward the call (sans the override) to Write-Log or Write-LogHelper if (Get-Command Write-Log -ErrorAction SilentlyContinue) { Write-Log @invokeParams } else { Write-LogHelper @invokeParams } } else { # No override: let Write-Log / Write-LogHelper handle everything (including console color) if (Get-Command Write-Log -ErrorAction SilentlyContinue) { Write-Log @invokeParams } else { Write-LogHelper @invokeParams } } } #endregion Write-Log #region Remote Assets + Task Loading 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 is available here (we placed this block after it) Write-LogHybrid "Get-RemoteText failed for ${Url}: $($_.Exception.Message)" Warning UI -LogToEvent return "" } } function Get-SamyTasks { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Url ) try { $json = Get-RemoteText -Url $Url if ([string]::IsNullOrWhiteSpace($json)) { throw "Tasks JSON was empty." } $tasks = @($json | ConvertFrom-Json -ErrorAction Stop) if ($tasks.Count -eq 0) { throw "Tasks JSON parsed but contained no tasks." } foreach ($t in $tasks) { $tooltipRaw = $null if ($t.PSObject.Properties.Name -contains 'Tooltip') { $tooltipRaw = $t.Tooltip } # Normalize tooltip to a single string $tooltip = if ($tooltipRaw -is [string]) { $tooltipRaw } elseif ($tooltipRaw -is [System.Collections.IEnumerable] -and -not ($tooltipRaw -is [string])) { $arr = @( $tooltipRaw | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } ) if ($arr.Count -eq 1) { $arr[0] # single tooltip item } else { [string]$t.Label # array or multiple items => treat as bad data, fallback to label } } else { [string]$tooltipRaw } # DEBUG: inspect label type $lbl = $t.Label if ($lbl -is [System.Collections.IEnumerable] -and -not ($lbl -is [string])) { Write-LogHybrid "LABEL IS ARRAY?! Id=$($t.Id) Type=$($lbl.GetType().FullName) Count=$(@($lbl).Count)" Warning UI -LogToEvent } # Fallback: tooltip defaults to label if ([string]::IsNullOrWhiteSpace($tooltip)) { $tooltip = [string]$t.Label } # Ensure property exists and is updated if ($t.PSObject.Properties.Name -contains 'Tooltip') { $t.Tooltip = $tooltip } else { $t | Add-Member -NotePropertyName Tooltip -NotePropertyValue $tooltip -Force } } return $tasks } catch { Write-LogHybrid "Failed to load tasks from ${Url}: $($_.Exception.Message)" Error UI -LogToEvent return $null } } #endregion Remote Assets + Task Loading #region Computer rename helpers 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 } #endregion Computer rename helpers #region building the Menus # Define every task once here: # Id → checkbox HTML `id` # Name → URL path (`/Name`) # Label → user-visible text # HandlerFn → the PowerShell function to invoke # Page → which tab/page it appears on $Global:SamyTasks = Get-SamyTasks -Url $Script:SamyTasksUrl if (-not $Global:SamyTasks) { throw "SAMY cannot continue: failed to load tasks from $Script:SamyTasksUrl" } Write-LogHybrid "Tooltip type check running. Tasks loaded: $($Global:SamyTasks.Count)" Info UI -LogToEvent # DEBUG: detect any Tooltip that is an array/list (will cause "mega-tooltips") $Global:SamyTasks | ForEach-Object { if ($_.PSObject.Properties.Name -contains 'Tooltip' -and $_.Tooltip -is [System.Collections.IEnumerable] -and -not ($_.Tooltip -is [string])) { Write-LogHybrid "BAD TOOLTIP TYPE: Id=$($_.Id) Type=$($_.Tooltip.GetType().FullName) ValueCount=$(@($_.Tooltip).Count)" Warning UI -LogToEvent } } Write-LogHybrid "Dumping tooltip lengths (to spot mega-tooltips)..." Info UI -LogToEvent $Global:SamyTasks | ForEach-Object { $tip = if ($_.PSObject.Properties.Name -contains 'Tooltip') { [string]$_.Tooltip } else { "" } $len = $tip.Length if ($len -gt 80) { $preview = $tip.Substring(0, [Math]::Min(160, $len)) Write-LogHybrid "LONG TOOLTIP: Id=$($_.Id) Len=$len Preview='$preview'" Warning UI -LogToEvent } } #endregion building the Menu #region Publish-Checkboxes function Publish-Checkboxes { param( [Parameter(Mandatory)][string]$Page, [string]$Column ) function Escape-HtmlAttr { param([string]$s) if ([string]::IsNullOrEmpty($s)) { return '' } $s = $s -replace "(`r`n|`r|`n)", ' ' $s = $s -replace '&','&' $s = $s -replace '"','"' $s = $s -replace "'",''' $s = $s -replace '<','<' $s = $s -replace '>','>' return $s } function Escape-HtmlText { param([string]$s) if ([string]::IsNullOrEmpty($s)) { return '' } $s = $s -replace '&','&' $s = $s -replace '<','<' $s = $s -replace '>','>' return $s } # Start with all tasks on the given page $tasks = $Global:SamyTasks | Where-Object Page -EQ $Page # Only filter by Column when it actually matters (onboard left/right) if (-not [string]::IsNullOrEmpty($Column)) { $tasks = $tasks | Where-Object Column -EQ $Column } ( $tasks | ForEach-Object { $taskId = $_.Id $rawTooltip = if ($_.PSObject.Properties.Name -contains 'Tooltip' -and $_.Tooltip) { [string]$_.Tooltip } else { [string]$_.Label } $tooltipText = Escape-HtmlAttr $rawTooltip $tooltipAttr = if ([string]::IsNullOrWhiteSpace($tooltipText)) { '' } else { " title=`"$tooltipText`"" } $labelText = Escape-HtmlText ([string]$_.Label) $taskIdAttr = Escape-HtmlAttr ([string]$taskId) $nameAttr = Escape-HtmlAttr ([string]$_.Name) $colAttr = Escape-HtmlAttr ([string]$Column) $html = " $labelText" if ($_.SubOptions) { $subHtml = ( $_.SubOptions | ForEach-Object { $subLabel = Escape-HtmlText ([string]$_.Label) $subTaskIdClass = Escape-HtmlAttr ([string]$taskId) $subValueAttr = Escape-HtmlAttr ([string]$_.Value) "" } ) -join "`n" $html += @" "@ } $html } ) -join "`n" } # end function Publish-Checkboxes #endregion Publish-Checkboxes #region Get-ModuleVersionHtml ### Get SVSMSP module version to display in the UI function Get-ModuleVersionHtml { $mod = Get-Module -ListAvailable -Name SVSMSP | Sort-Object Version -Descending | Select-Object -First 1 # Friendly branch label based on $Script:SamyBranch $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
" } #endregion Get-ModuleVersionHtml #region Strat-Server 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." } # Starts the HTTP listener loop 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 } } #endregion Strat-Server #region UIHtml function Get-UIHtml { param([string]$Page = 'onboard') if (-not $Page) { $Page = 'onboard' } # # 1) Build checkbox HTML per page/column # $onboardLeft = Publish-Checkboxes -Page 'onboard' -Column 'left' $onboardRight = Publish-Checkboxes -Page 'onboard' -Column 'right' $offboard = Publish-Checkboxes -Page 'offboard' -Column '' $devices = Publish-Checkboxes -Page 'devices' -Column '' # # 2) Build the JS tasks array once (this is the only dynamic JS piece) # $tasksJson = @( $Global:SamyTasks | ForEach-Object { [pscustomobject]@{ id = [string]$_.Id handler = "/$([string]$_.Name)" label = [string]$_.Label } } ) | ConvertTo-Json -Depth 4 # If you still want to keep the variable name "tasksJsAll", just store JSON in it: $tasksJsAll = $tasksJson # Human friendly branch label for UI $branchDisplay = switch ($Script:SamyBranch.ToLower()) { 'main' { 'Main / Stable' } 'beta' { 'Beta' } default { $Script:SamyBranch } } # # 3) Pull CSS/JS/HTML from Gitea and inline them # $cssContent = Get-RemoteText -Url $Script:SamyCssUrl $jsContent = Get-RemoteText -Url $Script:SamyJsUrl $htmlTemplate = Get-RemoteText -Url $Script:SamyHtmlUrl if (-not $htmlTemplate) { # Hard fail or fallback, your call. # This keeps the UI from becoming blank if the repo is unreachable. Write-LogHybrid "UI template download failed. Returning minimal error page." Error UI -LogToEvent return "

SAMY UI template unavailable

Could not download samy.html from repo.

" } # Optional: inject background override into CSS (same as you had) if ($cssContent) { $cssContent += @" /* SAMY background override injected by script */ .sidebar::after { background-image: url('$Script:SamyBgLogoUrl') !important; } "@ } # # 4) Replace placeholders (unchanged vs your version) # $html = $htmlTemplate $html = $html.Replace('{{CssContent}}', $cssContent) $html = $html.Replace('{{JsContent}}', $jsContent) $html = $html.Replace('{{SamyFaviconUrl}}', $Script:SamyFaviconUrl) $html = $html.Replace('{{SamyTopLogoUrl}}', $Script:SamyTopLogoUrl) $html = $html.Replace('{{SamyHintText}}', $Script:SamyHintText) $html = $html.Replace('{{moduleVersion}}', (Get-ModuleVersionHtml)) $html = $html.Replace('{{onboardLeftColumn}}', $onboardLeft) $html = $html.Replace('{{onboardRightColumn}}', $onboardRight) $html = $html.Replace('{{offboardCheckboxes}}', $offboard) $html = $html.Replace('{{devicesCheckboxes}}', $devices) $html = $html.Replace('{{tasksJsAll}}', $tasksJsAll) $html = $html.Replace('{{defaultPage}}', $Page) return $html } #endregion UIHtml #region Handler Stubs #region 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 } # --- Prevent caching (Edge app-mode loves to be "helpful") --- try { $Context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" $Context.Response.Headers["Pragma"] = "no-cache" $Context.Response.Headers["Expires"] = "0" } catch { # HttpListenerResponse headers can throw in rare cases; ignore } $bytes = [Text.Encoding]::UTF8.GetBytes($Html) # Include charset $Context.Response.ContentType = "text/html; charset=utf-8" $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 } $json = $null try { # Normalize output so GetBytes never sees $null 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 8 -ErrorAction Stop } catch { Write-LogHybrid "Send-JSON serialization failed: $($_.Exception.Message); returning empty JSON array." Error Printers -LogToEvent $json = '[]' } } $json = [string]$json # ---- No-cache headers (prevents stale UI data) ---- try { $Context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" $Context.Response.Headers["Pragma"] = "no-cache" $Context.Response.Headers["Expires"] = "0" } catch { } $bytes = [Text.Encoding]::UTF8.GetBytes($json) $Context.Response.ContentType = "application/json; charset=utf-8" $Context.Response.ContentLength64 = $bytes.Length $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) } catch { Write-LogHybrid "Send-JSON fatal error: $($_.Exception.Message)" Error Printers -LogToEvent # Best-effort fallback response try { $fallback = '[]' $bytes = [Text.Encoding]::UTF8.GetBytes($fallback) try { $Context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" $Context.Response.Headers["Pragma"] = "no-cache" $Context.Response.Headers["Expires"] = "0" } catch { } $Context.Response.ContentType = "application/json; charset=utf-8" $Context.Response.ContentLength64 = $bytes.Length $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) } catch { # swallow: nothing else we can do safely here } } finally { try { $Context.Response.OutputStream.Close() } catch { } } } #endregion HTTP responder helpers function Invoke-TasksCompleted { param($Context) Write-LogHybrid "All UI-selected tasks processed" Info UI -LogToEvent Send-Text $Context "Tasks completion acknowledged." } #region Datto handlers function Invoke-FetchSites { param($Context) try { # 1) Read the incoming JSON payload (contains only the webhook password) $raw = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd() $pw = (ConvertFrom-Json $raw).password # Store for next call (can be blank) $Global:WebhookPassword = [string]$pw # 2) Delegate to your unified function $sites = Install-DattoRMM ` -UseWebhook ` -WebhookPassword $pw ` -FetchSites ` # -SaveSitesList:$SaveSitesList ` # -OutputFile $OutputFile # 3) Return JSON array of sites Send-JSON $Context $sites } catch { # Log the exception and return HTTP 500 Write-LogHybrid "Invoke-FetchSites error: $($_.Exception.Message)" Error DattoRMM -LogToEvent $Context.Response.StatusCode = 500 Send-Text $Context "Internal server error fetching sites." } } #endregion Datto handlers #region Onboarding handlers # On-boarding handlers function Invoke-SetSVSPowerPlan { param($Context) # 1) call into your module Set-SVSPowerPlan # 2) log & write back a simple text response 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) # 1) call into your module Install-CyberQP # 2) log & write back a simple text response Write-LogHybrid "CyberQP installed" "Success" "OnBoard" Send-Text $Context "CyberQP installed" } function Invoke-InstallThreatLocker { param($Context) # 1) call into your module Install-ThreatLocker # 2) log & write back a simple text response Write-LogHybrid "ThreatLocker installed" "Success" "OnBoard" Send-Text $Context "ThreatLocker installed" } function Invoke-InstallRocketCyber { param($Context) # 1) call into your module Install-RocketCyber # 2) log & write back a simple text response Write-LogHybrid "RocketCyber installed" "Success" "OnBoard" Send-Text $Context "RocketCyber installed" } function Invoke-InstallHelpDesk { param($Context) # 1) call into your module Install-svsHelpDesk # 2) log & write back a simple text response 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 } # Read raw JSON body $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." } } #endregion Onboarding handlers function Invoke-InstallDattoRMM { param($Context) try { if ($Context.Request.HttpMethod -ne 'POST') { $Context.Response.StatusCode = 405 Send-Text $Context 'Use POST' return } # 1) Read and parse the JSON body $body = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd() $data = ConvertFrom-Json $body # 2) Delegate to your unified function for the install 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') # 3) Acknowledge to the client Send-Text $Context "Triggered DattoRMM for $($data.Name)" } catch { # Log the exception and return HTTP 500 Write-LogHybrid "Invoke-InstallDattoRMM error: $($_.Exception.Message)" Error DattoRMM -LogToEvent $Context.Response.StatusCode = 500 Send-Text $Context "Internal server error during DattoRMM install." } } #endregion Datto handlers #region 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)" } } #endregion App handlers #region Offboarding handlers function Invoke-UninstallCyberQP { param($Context) try { if (Get-Command Uninstall-CyberQP -ErrorAction Stop) { Uninstall-CyberQP Write-LogHybrid "CyberQP uninstalled" Success OffBoard -LogToEvent 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 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 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 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 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 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 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 Send-Text $Context "ERROR: $($_.Exception.Message)" } } function Invoke-CleanupSVSMSP { param($Context) try { if (Get-Command Install-SVSMSP -ErrorAction Stop) { # This will: # - Uninstall SVSMSP # - Unregister SVS_Repo # - Remove SVSMSP from the session # - Delete HKLM:\Software\SVS\Deployment (via Remove-SVSDeploymentRegKey) Install-SVSMSP -Cleanup Write-LogHybrid "SVSMSP toolkit cleanup completed (module, repo, registry)." Success OffBoard -LogToEvent 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 Send-Text $Context "ERROR: $($_.Exception.Message)" } } #endregion Offboarding handlers #region Printer handlers function Invoke-GetPrinters { param($Context) try { if ($Context.Request.HttpMethod -ne 'POST') { $Context.Response.StatusCode = 405 Send-Text $Context 'Use POST' return } # If printer cmdlets missing, try installing toolkit automatically if (-not (Get-Command Get-SvsPrinterProfilesFromServer -ErrorAction SilentlyContinue)) { Write-LogHybrid "SVSMSP cmdlets missing. Attempting Install-SVSMSP -InstallToolkit..." Warning Printers -LogToEvent try { Install-SVSMSP -InstallToolkit Import-Module SVSMSP -Force -ErrorAction SilentlyContinue } catch { Write-LogHybrid "Auto-install of SVSMSP failed: $($_.Exception.Message)" Error Printers -LogToEvent $Context.Response.StatusCode = 500 Send-Text $Context "SVSMSP auto-install failed. Run 'Install SVSMSP Module' manually." return } # Re-check after install if (-not (Get-Command Get-SvsPrinterProfilesFromServer -ErrorAction SilentlyContinue)) { Write-LogHybrid "SVSMSP installed but printer cmdlets still unavailable." Error Printers -LogToEvent $Context.Response.StatusCode = 500 Send-Text $Context "SVSMSP installed but printer commands still not available. Restart SAMY." return } } # Read JSON body: { "password": "..." } $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 } # Allow blank password (IP allowlist handled server-side) $password = [string]$body.password # $null -> '' if ($password -eq '') { Write-LogHybrid "Printer password is blank; relying on allowlisted IP (server-side)." Info Printers -LogToEvent } $uri = 'https://bananas.svstools.ca/getprinters' Write-LogHybrid "Fetching printers from $uri" Info Printers -LogToEvent $printers = Get-SvsPrinterProfilesFromServer -Uri $uri -Password $password if ($null -eq $printers) { $printers = @() } try { Set-SvsPrinterLocalConfig -PrinterProfiles $printers -SkipIfEmpty } catch { Write-LogHybrid "Set-SvsPrinterLocalConfig 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 } # Read JSON body $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 = [bool]($p.PSObject.Properties.Name -contains 'SetAsDefault' -and $p.SetAsDefault) if (-not $clientCode -or -not $profileName) { $msg = "Skipping printer entry: ClientCode or ProfileName missing." Write-LogHybrid $msg Warning Printers -LogToEvent $failures += $msg continue } $summary = "ClientCode=$clientCode ProfileName=$profileName SetAsDefault=$setDefault" Write-LogHybrid "Installing printer ($summary)" Info Printers -LogToEvent try { Invoke-SVSPrinterInstall ` -ClientCode $clientCode ` -ProfileName $profileName ` -SetAsDefault:$setDefault ` #-WhatIf $successCount++ Write-LogHybrid "Printer installed successfully ($summary)" Success Printers -LogToEvent } catch { $errMsg = "Failed to install printer ($summary): $($_.Exception.Message)" Write-LogHybrid $errMsg Error Printers -LogToEvent $failures += $errMsg } } Send-JSON $Context @{ SuccessCount = $successCount FailureCount = $failures.Count Failures = $failures Message = "Printer install processed. Check SAMY logs for detail." } } catch { Write-LogHybrid "Invoke-InstallPrinters error: $($_.Exception.Message)" Error Printers -LogToEvent $Context.Response.StatusCode = 500 Send-Text $Context "Internal server error installing printers." } } #endregion Printer handlers #endregion Handler Stubs #region Install-DattoRMM <# .SYNOPSIS Installs/configures the Datto RMM agent, fetches site lists, and optionally saves the site list to disk. .DESCRIPTION Centralizes Datto RMM operations in one function: - Fetch API credentials from a webhook (-UseWebhook) - Acquire OAuth token - Fetch site list (-FetchSites) - Save site list to Desktop as JSON or CSV (-FetchSites + -SaveSitesList) - Write site variables to registry (-PushSiteVars) - Download & launch the RMM agent installer (-InstallRMM) - Save a copy of the installer (-SaveCopy) .PARAMETER UseWebhook Fetches ApiUrl, ApiKey, and ApiSecretKey from the webhook when used with WebhookPassword. .PARAMETER WebhookPassword Password for authenticating to the credentials webhook. .PARAMETER WebhookUrl URL of the credentials webhook. Defaults to $Global:DattoWebhookUrl. .PARAMETER ApiUrl Direct Datto API endpoint URL (if not using webhook). .PARAMETER ApiKey Direct Datto API key (if not using webhook). .PARAMETER ApiSecretKey Direct Datto API secret (if not using webhook). .PARAMETER FetchSites Fetches the list of sites and skips all install steps. .PARAMETER SaveSitesList Saves the fetched site list to Desktop using OutputFile. Must be used with -FetchSites. .PARAMETER OutputFile Filename for saving the site list (.json or .csv). Defaults to 'datto_sites.csv'. .PARAMETER PushSiteVars Writes fetched site variables into HKLM:\Software\SVS\Deployment. .PARAMETER InstallRMM Downloads and runs the Datto RMM agent installer. .PARAMETER SaveCopy Saves a copy of the downloaded agent installer to C:\Temp. .PARAMETER SiteUID Unique identifier of the Datto site (required for install and registry push). .PARAMETER SiteName Friendly name of the Datto site (used for logging). .EXAMPLE # Fetch and save site list via webhook Install-DattoRMM -UseWebhook -WebhookPassword 'Tndmeeisdwge!' -FetchSites -SaveSitesList -OutputFile 'sites.csv' .EXAMPLE # Headless install with site variables Install-DattoRMM -ApiUrl 'https://api.example.com' -ApiKey 'KeyHere' -ApiSecretKey 'SecretHere' \ -SiteUID 'site-123' -SiteName 'Acme Corp' -PushSiteVars -InstallRMM .EXAMPLE # Download and save installer to C:\Temp without installing Install-DattoRMM -ApiUrl 'https://api.example.com' -ApiKey 'KeyHere' -ApiSecretKey 'SecretHere' \ -SiteUID 'site-123' -SiteName 'Acme Corp' -SaveCopy #> function Install-DattoRMM { [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 ) # Validate mutually-dependent switches if ($SaveSitesList -and -not $FetchSites) { Write-LogHybrid "-SaveSitesList requires -FetchSites." Error DattoRMM -LogToEvent; return } # 1) Optionally fetch credentials from webhook if ($UseWebhook) { # Allow blank for IP allowlist scenario. Only treat true $null as missing. if ($null -eq $WebhookPassword) { $WebhookPassword = '' Write-LogHybrid "Webhook password not provided (null). Treating as blank for allowlisted IP flow." Warning DattoRMM -LogToEvent } try { $resp = Invoke-RestMethod -Uri $WebhookUrl ` -Headers @{ SAMYPW = [string]$WebhookPassword } ` -Method GET ` -ErrorAction Stop $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 } } # 2) Validate API parameters if (-not $ApiUrl -or -not $ApiKey -or -not $ApiSecretKey) { Write-LogHybrid "Missing required API parameters." Error DattoRMM -LogToEvent; return } # 3) Acquire OAuth token [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" } # 4) Fetch site list only 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 @() } } # 5) Push site variables to registry 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 } } } # 6) Download & install RMM agent 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 } } } # 7) Save a copy of installer to C:\Temp 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 } } # 8) Warn if no action was taken if (-not ($PushSiteVars -or $InstallRMM -or $SaveCopy)) { Write-LogHybrid "No action specified. Use -FetchSites, -SaveSitesList, -PushSiteVars, -InstallRMM, or -SaveCopy." Warning DattoRMM -LogToEvent } } #endregion Install-DattoRMM #region Dispatch-Request # Sends the HTML for a given page or invokes a task handler function Dispatch-Request { param($Context) # figure out the path $path = $Context.Request.Url.AbsolutePath.TrimStart('/') # ---- Shutdown handler ---- if ($path -eq 'quit') { Write-LogHybrid "Shutdown requested" "Info" "Server" -LogToEvent Send-Text $Context "Server shutting down." # stop the listener loop $Global:Listener.Stop() return } # ---- Tasks completed notification ---- if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'tasksCompleted') { Invoke-TasksCompleted $Context return } # ---- Fetch Sites endpoint ---- if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'getpw') { Invoke-FetchSites $Context return } # ---- Rename Computer endpoint ---- if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'renameComputer') { Invoke-RenameComputer $Context return } # ---- Printer endpoints ---- 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 } # ---- Serve UI pages ---- if ($path -in @('', 'onboard', 'offboard', 'devices')) { $page = if ($path -eq '') { 'onboard' } else { $path } $html = Get-UIHtml -Page $page Send-HTML $Context $html return } # ---- Task invocation ---- $task = $Global:SamyTasks | Where-Object Name -EQ $path if ($task) { $fn = $task.HandlerFn $cmd = Get-Command $fn -ErrorAction SilentlyContinue if (-not $cmd) { $Context.Response.StatusCode = 500 Send-Text $Context "Handler not found: $fn" return } # If the handler declares a Context parameter, pass it by name. if ($cmd.Parameters.ContainsKey('Context')) { & $fn -Context $Context } else { & $fn } return } # ---- 404 ---- $Context.Response.StatusCode = 404 Send-Text $Context '404 - Not Found' } #endregion Dispatch-Request #region EntryPoint: Define Invoke-ScriptAutomationMonkey # ───────────────────────────────────────────────────────────────────────── # 3) MAIN LOGIC (Toolkit vs DattoFetch vs DattoInstall vs UI) # ───────────────────────────────────────────────────────────────────────── switch ($PSCmdlet.ParameterSetName) { 'Toolkit' { Write-LogHybrid "Toolkit-only mode" Info Startup -LogToEvent Install-SVSMSP -InstallToolkit return } 'Cleanup' { Write-LogHybrid "Running Toolkit cleanup mode" Info Startup -LogToEvent Install-SVSMSP -Cleanup return } # ─────────────────────────────────────────────────────────── # 2) If user only wants the site list, do that and exit # ─────────────────────────────────────────────────────────── 'DattoFetch' { Write-LogHybrid "Fetching site list only…" Info DattoAuth -LogToEvent $sites = Install-DattoRMM ` -UseWebhook ` -WebhookPassword $WebhookPassword ` -FetchSites ` -SaveSitesList:$SaveSitesList ` -OutputFile $OutputFile Write-LogHybrid "Done." Success DattoAuth -LogToEvent return } # ──────────────────────────────────────────── # 3) Invoke the existing Install-DattoRMM cmdlet # ──────────────────────────────────────────── 'DattoInstall' { Write-LogHybrid "Headless DattoRMM deploy" Info DattoAuth -LogToEvent if ($PSCmdlet.ShouldProcess("Datto site '$SiteName'", "Headless install")) { Install-DattoRMM ` -UseWebhook ` -WebhookPassword $WebhookPassword ` -SiteUID $SiteUID ` -SiteName $SiteName ` -PushSiteVars:$PushSiteVars ` -InstallRMM:$InstallRMM ` -SaveCopy:$SaveCopy } return } 'Offboard' { Write-LogHybrid "Headless offboarding requested" Info OffBoard -LogToEvent $offboardTasks = $Global:SamyTasks | Where-Object Page -EQ 'offboard' if (-not $offboardTasks) { Write-LogHybrid "No offboard tasks configured" Warning OffBoard -LogToEvent return } if (-not $PSCmdlet.ShouldProcess("Full off-boarding flow", "Execute every offboard task")) { return } foreach ($task in $offboardTasks) { try { Write-LogHybrid "Running offboard task: $($task.Label)" Info OffBoard -LogToEvent if (-not (Get-Command $task.HandlerFn -ErrorAction SilentlyContinue)) { Write-LogHybrid "Missing handler $($task.HandlerFn)" Error OffBoard -LogToEvent continue } & $task.HandlerFn $null } catch { Write-LogHybrid "Offboard task $($task.Label) failed: $($_.Exception.Message)" Error OffBoard -LogToEvent } } Write-LogHybrid "Headless offboarding completed" Success OffBoard -LogToEvent return } 'UI' { $url = "http://localhost:$Port/" Write-LogHybrid "Starting ScriptAutomationMonkey UI on $url" Info Startup # Resolve Edge path explicitly (x86 first, then 64-bit, then PATH) $edgeCandidates = @( "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe" ) $edgePath = $edgeCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1 if (-not $edgePath) { $cmd = Get-Command -Name 'msedge.exe' -ErrorAction SilentlyContinue if ($cmd) { $edgePath = $cmd.Path } } # Launch Edge (app mode) in a background job so Start-Server can block Start-Job -Name 'OpenScriptAutomationMonkeyUI' -ScriptBlock { param([string]$u, [string]$edge) Start-Sleep -Milliseconds 400 try { if ($edge -and (Test-Path $edge)) { Start-Process -FilePath $edge -ArgumentList @('--new-window', "--app=$u") } else { Start-Process -FilePath $u # fallback to default browser } } catch { } } -ArgumentList $url, $edgePath | Out-Null # Now start the blocking listener loop Start-Server return } } #endregion EntryPoint: Define Invoke-ScriptAutomationMonkey } if ($MyInvocation.InvocationName -eq '.') { # dot-sourced, don't invoke } elseif ($PSCommandPath) { # script was saved and run directly Invoke-ScriptAutomationMonkey @PSBoundParameters } else { # iwr | iex fallback if ($args.Count -gt 0) { # Convert -Param value -Switch into a hashtable for splatting $namedArgs = @{} for ($i = 0; $i -lt $args.Count; $i++) { if ($args[$i] -is [string] -and $args[$i].StartsWith('-')) { $key = $args[$i].TrimStart('-') $next = $args[$i + 1] if ($next -and ($next -notlike '-*')) { $namedArgs[$key] = $next $i++ # Skip next one, it's the value } else { $namedArgs[$key] = $true } } } Invoke-ScriptAutomationMonkey @namedArgs } else { Invoke-ScriptAutomationMonkey } }