Files
SAMY/samy.ps1
2025-12-21 02:26:27 -05:00

2093 lines
76 KiB
PowerShell

<#
.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 = 'beta' # '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 '&','&amp;'
$s = $s -replace '"','&quot;'
$s = $s -replace "'",'&#39;'
$s = $s -replace '<','&lt;'
$s = $s -replace '>','&gt;'
return $s
}
function Escape-HtmlText {
param([string]$s)
if ([string]::IsNullOrEmpty($s)) { return '' }
$s = $s -replace '&','&amp;'
$s = $s -replace '<','&lt;'
$s = $s -replace '>','&gt;'
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 = "<label$tooltipAttr><input type=""checkbox"" id=""$taskIdAttr"" name=""$nameAttr"" data-column=""$colAttr""> $labelText</label>"
if ($_.SubOptions) {
$subHtml = (
$_.SubOptions |
ForEach-Object {
$subLabel = Escape-HtmlText ([string]$_.Label)
$subTaskIdClass = Escape-HtmlAttr ([string]$taskId)
$subValueAttr = Escape-HtmlAttr ([string]$_.Value)
"<label style=""margin-left:20px; display:block;"">
<input type=""checkbox"" class=""sub-option-$subTaskIdClass"" name=""$subValueAttr"" value=""$subValueAttr""> $subLabel
</label>"
}
) -join "`n"
$html += @"
<div id="$(Escape-HtmlAttr ([string]$taskId))OptionsContainer" style="display:none; margin-top:4px;">
$subHtml
</div>
"@
}
$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 "<div style='color:#bbb; font-size:0.9em; margin-top:1em;'>
Module Version: $($mod.Version)<br>
UI Branch: $branchDisplay
</div>"
}
return "<div style='color:#f66;'>SVSMSP_Module not found</div>"
}
#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 "<html><body style='font-family:Segoe UI; padding:20px; background:#111; color:#ddd;'>
<h2>SAMY UI template unavailable</h2>
<p>Could not download samy.html from repo.</p>
</body></html>"
}
# 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
}
}