<# .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' # 1) Params / defaults / branch selection logic (change branch or base once and it updates everything) $Script:SamyRepoBase = 'https://git.svstools.ca/SVS_Public_Repo/SAMY/raw/branch' $Script:SamyBranch = 'main' # 'main' or 'beta' if (Get-Command Set-SvsPrinterRepoConfig -ErrorAction SilentlyContinue) { Set-SvsPrinterRepoConfig -RepoBase $Script:SamyRepoBase -Branch $Script:SamyBranch } # 2) Build all remote URLs $Script:SamyTopLogoUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SVS_logo.svg?raw=1" $Script:SamyBgLogoUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SAMY.png?raw=1" $Script:SamyFaviconUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SVS_Favicon.ico?raw=1" $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:SamyTasksUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.tasks.json?raw=1" $Script:SamyFunctionsUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.functions.ps1?raw=1" # Misc $Script:SamyHintText = "" # 3) Load remote functions (must be before calling them) try { $functionsContent = (Invoke-WebRequest -UseBasicParsing $Script:SamyFunctionsUrl -ErrorAction Stop).Content } catch { throw "Failed to download samy.functions.ps1 from $Script:SamyFunctionsUrl. $($_.Exception.Message)" } . ([ScriptBlock]::Create($functionsContent)) # 3) Load remote functions (must be before calling them) try { Write-Host "[Info] Loading functions from: $Script:SamyFunctionsUrl" -ForegroundColor Cyan $functionsContent = (Invoke-WebRequest -UseBasicParsing $Script:SamyFunctionsUrl -ErrorAction Stop).Content if ([string]::IsNullOrWhiteSpace($functionsContent)) { throw "Downloaded content was empty." } # quick sanity check: make sure the file looks like it contains your function if ($functionsContent -notmatch '(?im)^\s*function\s+Initialize-NuGetProvider\b') { Write-Host "[Warning] samy.functions.ps1 loaded, but Initialize-NuGetProvider not found in content." -ForegroundColor Yellow } # Load functions into the current scope . ([ScriptBlock]::Create($functionsContent)) # Verify the function is now available if (Get-Command Initialize-NuGetProvider -ErrorAction SilentlyContinue) { Write-Host "[Success] Initialize-NuGetProvider is loaded and available." -ForegroundColor Green } else { throw "Dot-sourcing completed, but Initialize-NuGetProvider is still not available." } } catch { throw "Failed to load samy.functions.ps1 from $Script:SamyFunctionsUrl. $($_.Exception.Message)" } # 4) Now call functions that live in samy.functions.ps1 # Initialize-NuGetProvider # 5) Continue with rest of samy.ps1 logic (UI, CSS/JS/HTML download, etc.) # 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 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 global: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 global: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." } $parsed = $json | ConvertFrom-Json -ErrorAction Stop $tasks = @($parsed) if ($tasks.Count -eq 0) { throw "Tasks JSON parsed but contained no tasks." } foreach ($task in $tasks) { # --- Normalize Label to a safe scalar string (never join arrays) --- $labelRaw = $task.Label if ($labelRaw -is [System.Collections.IEnumerable] -and -not ($labelRaw -is [string])) { $labelRaw = @($labelRaw)[0] } $label = [string]$labelRaw $task.Label = $label # --- Read Tooltip if present --- $tooltipRaw = $null if ($task.PSObject.Properties.Name -contains 'Tooltip') { $tooltipRaw = $task.Tooltip } # --- Normalize Tooltip to a safe scalar string (never join arrays) --- $tooltip = if ($tooltipRaw -is [string]) { $tooltipRaw } elseif ($tooltipRaw -is [System.Collections.IEnumerable] -and -not ($tooltipRaw -is [string])) { [string](@($tooltipRaw)[0]) } else { [string]$tooltipRaw } # --- Fallback to *this task's* label only --- if ([string]::IsNullOrWhiteSpace($tooltip)) { $tooltip = $label } # --- Ensure Tooltip property exists and is updated --- if ($task.PSObject.Properties.Name -contains 'Tooltip') { $task.Tooltip = $tooltip } else { $task | 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 $Global:SamyTasks = Get-SamyTasks -Url $Script:SamyTasksUrl if (-not $Global:SamyTasks) { throw "SAMY cannot continue: failed to load tasks from $Script:SamyTasksUrl" } # 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 } } $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 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 } }