├── .gitignore ├── Pax8 Invoice Analysis for Azure, AVD, Nerdio ├── Invoice-Analysis.ps1 ├── README.md └── SAMPLE-Company-Specific-Functions.ps1 ├── README.md ├── Remove N-Central Agent ├── README.md └── Remove-N-Central-Agent.ps1 ├── ScreenConnect Self-Hosted LetsEncrypt Certs ├── BindNewScreenConnectCert.cmd └── README.md ├── ScreenConnect Server └── Upgrade-ScreenConnect-Server.ps1 ├── Windows-Security-Hardening ├── Disable-PowerShell-V2.ps1 ├── Harden-Security-Windows-Registry.ps1 └── README.md └── immy.bot Scripts ├── DefensX Agent ├── DefensXDynamicVersions.ps1 ├── DefensXShield.png ├── DefensXSilentInstall.ps1 ├── README.md └── assets │ ├── screenshot_Deploying Defen_Image-1.png │ ├── screenshot_Deploying Defen_Image-2.png │ ├── screenshot_Deploying Defen_Image-3.png │ ├── screenshot_Deploying Defen_Image-4.png │ └── screenshot_Deploying Defen_Image.png └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.plist 2 | .DS_Store 3 | *.fossil 4 | .Ulysses* 5 | *.fossil 6 | -------------------------------------------------------------------------------- /Pax8 Invoice Analysis for Azure, AVD, Nerdio/Invoice-Analysis.ps1: -------------------------------------------------------------------------------- 1 | <# Invoice-Analysis.ps1 for Pax8 Invoice CSVs for Nerdio, Azure, and CloudJumper (retired) cost analysis 2 | 3 | # HOW TO USE: 4 | ## Prerequisites 5 | - Script should be in its own folder 6 | - Inside the folder, create a subfolder named "invoices" and put a CSV file from Pax8 (Billing, Invoices tab, "CSV" download button in Actions column for each month) into the folder. 7 | - If you need to noramlize/transform one company/ID to another from the source Pax8 files to your reports, copy `SAMPLE-Company-Specific-Functions.ps1` to remove the `SAMPLE-` prefix and 8 | edit with your changes. 9 | - When running the script, it should have permissions to create folders in the current folder, and write files into the subfolder. 10 | - This script has only been tested with PowerShell 7.4, though it may work with other versions. 11 | 12 | The reason for the `Company-Specific-Functions.ps1` script is because we've encountered the following cases where we need to normalize data for our reports: 13 | - Pax8 wasn't set up to assign certain licenses from our internal company to the correct one initially and the reporting will forever be 14 | incorrect in the Pax8 file, so we adjust. 15 | - Two companies merged into one and we want the report to reflect totals as if all historical billing were for one of the two companies. 16 | 17 | If you don't need to make adjustments like this, you can ignore this file as the script checks to see if it exists, and also checks to confirm 18 | that the `Normalize-Row-Data` function exists (from that file) before calling it, so it will run fine without. The function, if it does exist, 19 | directly accesses the current $row values from the main script for comparisons, and then updates the original variables $companyName and $companyID 20 | from that loop iteration (by using $script:companyName and $script:companyID to access the variables from the script scope); there are no variables 21 | passed to or from the script. This isn't neceessarily the cleanest code, but it was the simplest way to extract the code with customer names out 22 | of the main script to make it sharable, providing an example for you to use, and making its use optional if you don't need it. 23 | 24 | ## Running The Script 25 | Once all prerequisites are good to go, inside the script folder from a pwsh PowerShell prompt, run the script: 26 | 27 | `./Invoice-Anslysis.ps1` 28 | 29 | You can optionally provide command-line arguments to override some of the settings, if desired, with these named arguments (all optional with solid defaults): 30 | - CsvFolderPath 31 | - OutputPath 32 | - OutputFile 33 | - RawOutputFile 34 | 35 | You can also pass the -Verbose switch to substantially increase the detail output to the screen during the run for diagnostic purposes. 36 | 37 | By default, the script will validate that the "invoices" subfolder exists, will create (if it doesn't exist) a subfolder named yyyy-MM based on the date you're 38 | running the script, will loop through all the invoice .csv files in the "invoices" subfolder, perform it's analysis, and create two output files in the 39 | yyyy-MM folder, one named "analysis_raw.csv" and the other named "analysis.csv". If you pass different values to the parameters, the outputs will change. 40 | 41 | See the param() block at the start of the code for the default values and the formatting of the argument inputs. 42 | 43 | The `analysis.csv` file will contain one line per client-month with these columns (definitions in parentheses): 44 | - company_name (the client's name from the Pax8 file, or as modified/transformed in your normalization) 45 | - subtotal (the retail price subtotal of matching lines for the billing period from the Pax8 file) 46 | - cost_total (same as subtotal but the sum of cost_total instead rather than price) 47 | - margin (the difference between subtotal and cost_total columns) 48 | - start_period (always the FIRST day of the month for the period in which all charges in that line were pulled from in the Pax8 .csv files) 49 | 50 | The `analysis_raw.csv` file should basically have the lines from the original Pax8 .csv invoice files, but filtered to be only the ones that were used in 51 | calculating the analysis.csv data. 52 | 53 | The items that are included in the analysis should be, if any: 54 | - Azure arrears-billable consumption items, including Reserved Instances 55 | - Microsoft subscription Windows licensing 56 | - Nerdio licensing 57 | - CloudJumper licenses (this service, purchased by NetApp, no longer exists but a client used to use it before moving to AVD) 58 | 59 | All lines with a `subtotal` field equal to $0 will be skipped/excluded since it doesn't affect the totals. You can find the line of the script where 60 | `$filteredData` is defined to adjust your own version of which SKUs from the Pax8 invoices file will be included or not if you need to see or 61 | customize this for your purposes. 62 | 63 | ## How We Analyze Further 64 | 65 | We take the output and open the two resulting .csv files from the subfolder in Excel and Save As .xlsx files in the same folder, to make saving formatting 66 | and updates easier. 67 | 68 | It's not yet provided with sample data, but we take the resulitng `analysis.csv` file and use it to build a pivot table in Excel, where we add new lines each month 69 | and have the following additional columns we add to the five in `analysis.csv` which we then save as the file `new_analysis-thru-yyyy-MM.xlsx` in the same folder: 70 | - retail_price (we manually update the price these items were sold to the client from their invoice to this column of the spreadsheet) 71 | - margin_from_price_vs_cost (calculated value of the retail_price column minus the cost_total column, for us the Excel formula is `=F2-C2`) 72 | - margin_percent (calculated value of margin_from_price_vs_cost divided by the retail_price column (for us the Excel formula is `=G2/F2`)) 73 | - invoice_month (calculated MONTH function value of the start_period value, for us the Excel formula is `=MONTH(E2)`) 74 | - invoice_year (calculated YEAR function value of the start_period value, for us the Excel formula is `=YEAR(E2)`) 75 | - invoice_yearmonth (calculated zero-padded yyyy-MM value string from the invoice_year and invoice_month columns, for us the Excel formula is `=J2 & "-" & TEXT(I2,"00")`) 76 | 77 | From here, we can create or update two pivot tables with the above data as the source. The outcome is a pivot table with columns for client name, 78 | margin % average, total margin, total cost, and invoiced price, and a tree of rows we can expand and subtotal by client, year, or month. There are 79 | not currently samples of these additional fields or the pivot tables with sample data that have been created, so they're left as an exercise to 80 | the reader in the initial release. You can do whatever other analysis you wish, this is just how we use it so we can have a repeatable/updated 81 | process to get updated information regularly. 82 | 83 | # VERSION HISTORY: 84 | ## V9 is a complete refactor of V8 on 2023-12-05 that changes the following (original version details not tracked): 85 | - Reads in all invoice*.csv files at once to one large array. 86 | - Filters the data using a hash that combines the company name and the Start Period date (SP_ID) as the key. 87 | - Moves the company_name to be a separate field in the output since the key is no longer just company name. 88 | - All final analysis output is based on the actual month in which an individual charge was for, regardless of invoice. 89 | - Loses the name of the invoice file in the output but it isn't relevant for the final analysis. 90 | - Data can be copied to an Excel spreadsheet with pivot table that pulls some date info out and collects invoice amounts. 91 | - Removes the need for the Invoice-More-Summary.ps1 script to do further processing since the above replaces it. 92 | - Removes the redundant assignment of $results to $allResults since there's no looping through CSVs now to collect. 93 | - Removes multiple += array operator usages and increases performance. 94 | 95 | ## V10 is a minor tweak of V9 on 2023-12-06 that changes the following: 96 | - Removes old debug comments to clean up code. 97 | - Sets defaults for parameters after moving invoices around, also changes parameter names. 98 | - Fixes parsing of company name for Nerdio licenses and ignores parent company free licenses. 99 | 100 | ## V11 is an adjustment on 2024-06-13 that changes the following: 101 | - Add third company to the list of companies with Nerdio licenses. 102 | - Refactors the code that updates the company for Nerdio licenses based on the Description field to properly output it in the summary. 103 | - Adds some Verbose output for debugging the Nderio code activated if the -Verbose flag is passed. 104 | 105 | ## V12 is an adjustment on 2024-07-15 that changes the following for the first public release: 106 | - Moves any client names and client-specific normalization into external Company-Specific-Functions.ps1 file to enable sharing without compromising privacy. 107 | - Documenteation added for how to use the script to to the top in markdown format in order to create sharable version. 108 | #> 109 | [CmdletBinding()] 110 | param ( 111 | [Parameter(Mandatory = $false)] 112 | [ValidateScript({ Test-Path $_ -PathType 'Container' })] 113 | [string]$CsvFolderPath = '.\invoices', 114 | 115 | [Parameter(Mandatory = $false)] 116 | [string]$OutputPath = (".\" + (Get-Date -Format "yyyy-MM")), 117 | 118 | [Parameter(Mandatory = $false)] 119 | [string]$OutputFile = 'analysis.csv', 120 | 121 | [Parameter(Mandatory = $false)] 122 | [string]$RawOutputFile = 'analysis_raw.csv' 123 | ) 124 | 125 | # Verify if the folder to check exists 126 | if (-Not (Test-Path -Path $CsvFolderPath)) { 127 | Write-Error "The folder '$CsvFolderPath' does not exist." 128 | exit 1 129 | } 130 | 131 | # Get all CSV files in the specified folder that start with "invoice" 132 | $csvFiles = Get-ChildItem -Path $CsvFolderPath -Filter "invoice*.csv" -File 133 | if ($csvFiles.Count -eq 0) { 134 | Write-Error "No CSV files found in the folder '$CsvFolderPath'." 135 | exit 1 136 | } 137 | 138 | # Verify if the output path exists, create it if not 139 | if (-Not (Test-Path -Path $outputPath)) { 140 | Write-Host "The output path '$outputPath' does not exist. Creating it now." 141 | New-Item -ItemType Directory -Path $outputPath | Out-Null 142 | } 143 | 144 | # Join the output path with the filename 145 | $OutputFilePath = Join-Path -Path $outputPath -ChildPath $OutputFile 146 | $RawOutputFilePath = Join-Path -Path $outputPath -ChildPath $RawOutputFile 147 | 148 | Write-Host "Reading files from $CsvFolderPath" 149 | Write-Host "Output file: $OutputFilePath" 150 | Write-Host "Raw output file: $RawOutputFilePath" 151 | Write-Host "These files will be overwritten if they already exist." 152 | 153 | # Import all CSV file lines into the $data variable 154 | $data = Get-ChildItem -Path $CsvFolderPath -Filter "invoice*.csv" -File | ForEach-Object { 155 | Import-Csv -Path $_ 156 | } 157 | 158 | # Create an empty array list object to store the raw results 159 | # Create a List to store the raw data 160 | $rawData = New-Object System.Collections.Generic.List[object] 161 | 162 | # Filter the data based on the sku field to only include Azure, Microsoft subscription Windows licensing, Nerdio, and CloudJumper (old) licenses 163 | $filteredData = $data | 164 | Where-Object { $_.sku -like "MST-AZR*" -or $_.sku -like "MST-ARI*" -or $_.sku -like "AZR-ARR-*" -or $_.sku -like "NIO-INF*" -or $_.sku -like "CJR-*" } | 165 | Sort-Object -Property company_name, start_period 166 | 167 | # Create a hashtable to store the results 168 | $companySums = @{} 169 | 170 | # Load in any Company-Specific-Functions file, if it exists, to allow for normalization of company name/ID specific to organization: 171 | if (Test-Path -PathType Leaf -Path "./Company-Specific-Functions.ps1") { 172 | . "./Company-Specific-Functions.ps1" 173 | } 174 | else { 175 | Write-Host "No Company-Specific-Functions.ps1 file found in current folder, skipping these normalization/transformation calls." 176 | } 177 | 178 | # Iterate over each row in the filtered data 179 | foreach ($row in $filteredData) { 180 | $companyName = $row.company_name 181 | $companyID = $row.company_id 182 | $subtotal = [decimal]$row.subtotal 183 | $costTotal = [decimal]$row.cost_total 184 | $startPeriod = [datetime]$row.start_period 185 | 186 | # First skip the line if the subtotal is $0 because it doesn't matter to the result. 187 | if ($script:subtotal -eq 0) { 188 | continue 189 | } 190 | 191 | # Call function in Comapany-Specific-Functions.ps1 script to normalize some company names/IDs by updating $script:companyName and $script:companyID to 192 | # new values based on the values of $row.details. If the function doesn't exist, skip the call. 193 | if (Get-Command "Normalize-Row-Data-General" -ErrorAction SilentlyContinue) { 194 | Normalize-Row-Data 195 | } 196 | 197 | $row.company_name = $companyName 198 | $row.company_id = $companyID 199 | 200 | # Set the date to the first day of the month and make it the $sp_id 201 | $startPeriod = $startPeriod.Date.AddDays(1 - $startPeriod.Day) 202 | $sp_id = $startPeriod.ToString("yyyy-MM-dd") 203 | 204 | # If the company name is not already in the hashtable, add it 205 | if (-not $companySums.ContainsKey("$companyName-$sp_id")) { 206 | $companySums["$companyName-$sp_id"] = @{ 207 | company_name = $companyName 208 | subtotal = 0 209 | costTotal = 0 210 | margin = 0 211 | start_period = $startPeriod 212 | } 213 | } 214 | 215 | # Update the sums for the company 216 | $companySums["$companyName-$sp_id"].subtotal += $subtotal 217 | $companySums["$companyName-$sp_id"].costTotal += $costTotal 218 | $companySums["$companyName-$sp_id"].margin += ($subtotal - $costTotal) 219 | 220 | # Add the raw data to the $rawData array 221 | $rawData.Add($row) 222 | } 223 | 224 | # Convert the hashtable to an array of objects 225 | $results = foreach ($key in $companySums.Keys) { 226 | [PSCustomObject]@{ 227 | key = $key 228 | company_name = $companySums[$key].company_name 229 | subtotal = $companySums[$key].subtotal 230 | cost_total = $companySums[$key].costTotal 231 | margin = $companySums[$key].margin 232 | start_period = $companySums[$key].start_period.ToString("yyyy-MM-dd") 233 | } 234 | } 235 | 236 | # Sort the results alphabetically by company_name, and then by oldest-first date order by start_period 237 | $results = $results | Sort-Object -Property @{Expression = "company_name"; Ascending = $true }, @{Expression = "start_period"; Ascending = $true } 238 | 239 | # Export the results to a CSV file 240 | $results | Select-Object company_name, subtotal, cost_total, margin, start_period | Export-Csv -Path $OutputFilePath -NoTypeInformation 241 | 242 | # Export the raw results to a CSV file 243 | $rawData | Export-Csv -Path $RawOutputFilePath -NoTypeInformation 244 | -------------------------------------------------------------------------------- /Pax8 Invoice Analysis for Azure, AVD, Nerdio/README.md: -------------------------------------------------------------------------------- 1 | # Invoice-Analysis.ps1 for Pax8 Invoice CSVs for Nerdio, Azure, and CloudJumper (retired) cost analysis 2 | 3 | # HOW TO USE: 4 | ## Prerequisites 5 | - Script should be in its own folder 6 | - Inside the folder, create a subfolder named "invoices" and put a CSV file from Pax8 (Billing, Invoices tab, "CSV" download button in Actions column for each month) into the folder. 7 | - If you need to normalize/transform one company/ID to another from the source Pax8 files to your reports, copy `SAMPLE-Company-Specific-Functions.ps1` to remove the `SAMPLE-` prefix and 8 | edit with your changes. 9 | - When running the script, it should have permissions to create folders in the current folder, and write files into the subfolder. 10 | - This script has only been tested with PowerShell 7.4, though it may work with other versions. 11 | 12 | The reason for the `Company-Specific-Functions.ps1` script is because we've encountered the following cases where we need to normalize data for our reports: 13 | - Pax8 wasn't set up to assign certain licenses from our internal company to the correct one initially and the reporting will forever be 14 | incorrect in the Pax8 file, so we adjust. 15 | - Two companies merged into one and we want the report to reflect totals as if all historical billing were for one of the two companies. 16 | 17 | If you don't need to make adjustments like this, you can ignore this file as the script checks to see if it exists, and also checks to confirm 18 | that the `Normalize-Row-Data` function exists (from that file) before calling it, so it will run fine without. The function, if it does exist, 19 | directly accesses the current $row values from the main script for comparisons, and then updates the original variables $companyName and $companyID 20 | from that loop iteration (by using $script:companyName and $script:companyID to access the variables from the script scope); there are no variables 21 | passed to or from the script. This isn't necessarily the cleanest code, but it was the simplest way to extract the code with customer names out 22 | of the main script to make it sharable, providing an example for you to use, and making its use optional if you don't need it. 23 | 24 | ## Running The Script 25 | Once all prerequisites are good to go, inside the script folder from a pwsh PowerShell prompt, run the script: 26 | 27 | `./Invoice-Anslysis.ps1` 28 | 29 | You can optionally provide command-line arguments to override some of the settings, if desired, with these named arguments (all optional with solid defaults): 30 | - CsvFolderPath 31 | - OutputPath 32 | - OutputFile 33 | - RawOutputFile 34 | 35 | You can also pass the -Verbose switch to substantially increase the detail output to the screen during the run for diagnostic purposes. 36 | 37 | By default, the script will validate that the "invoices" subfolder exists, will create (if it doesn't exist) a subfolder named yyyy-MM based on the date you're 38 | running the script, will loop through all the invoice .csv files in the "invoices" subfolder, perform it's analysis, and create two output files in the 39 | yyyy-MM folder, one named "analysis_raw.csv" and the other named "analysis.csv". If you pass different values to the parameters, the outputs will change. 40 | 41 | See the param() block at the start of the code for the default values and the formatting of the argument inputs. 42 | 43 | The `analysis.csv` file will contain one line per client-month with these columns (definitions in parentheses): 44 | - company_name (the client's name from the Pax8 file, or as modified/transformed in your normalization) 45 | - subtotal (the retail price subtotal of matching lines for the billing period from the Pax8 file) 46 | - cost_total (same as subtotal but the sum of cost_total instead rather than price) 47 | - margin (the difference between subtotal and cost_total columns) 48 | - start_period (always the FIRST day of the month for the period in which all charges in that line were pulled from in the Pax8 .csv files) 49 | 50 | The `analysis_raw.csv` file should basically have the lines from the original Pax8 .csv invoice files, but filtered to be only the ones that were used in 51 | calculating the analysis.csv data. 52 | 53 | The items that are included in the analysis should be, if any: 54 | - Azure arrears-billable consumption items, including Reserved Instances 55 | - Microsoft subscription Windows licensing 56 | - Nerdio licensing 57 | - CloudJumper licenses (this service, purchased by NetApp, no longer exists but a client used to use it before moving to AVD) 58 | 59 | All lines with a `subtotal` field equal to $0 will be skipped/excluded since it doesn't affect the totals. You can find the line of the script where 60 | `$filteredData` is defined to adjust your own version of which SKUs from the Pax8 invoices file will be included or not if you need to see or 61 | customize this for your purposes. 62 | 63 | ## How We Analyze Further 64 | 65 | We take the output and open the two resulting .csv files from the subfolder in Excel and Save As .xlsx files in the same folder, to make saving formatting 66 | and updates easier. 67 | 68 | It's not yet provided with sample data, but we take the resulting `analysis.csv` file and use it to build a pivot table in Excel, where we add new lines each month 69 | and have the following additional columns we add to the five in `analysis.csv` which we then save as the file `new_analysis-thru-yyyy-MM.xlsx` in the same folder: 70 | - retail_price (we manually update the price these items were sold to the client from their invoice to this column of the spreadsheet) 71 | - margin_from_price_vs_cost (calculated value of the retail_price column minus the cost_total column, for us the Excel formula is `=F2-C2`) 72 | - margin_percent (calculated value of margin_from_price_vs_cost divided by the retail_price column (for us the Excel formula is `=G2/F2`)) 73 | - invoice_month (calculated MONTH function value of the start_period value, for us the Excel formula is `=MONTH(E2)`) 74 | - invoice_year (calculated YEAR function value of the start_period value, for us the Excel formula is `=YEAR(E2)`) 75 | - invoice_yearmonth (calculated zero-padded yyyy-MM value string from the invoice_year and invoice_month columns, for us the Excel formula is `=J2 & "-" & TEXT(I2,"00")`) 76 | 77 | From here, we can create or update two pivot tables with the above data as the source. The outcome is a pivot table with columns for client name, 78 | margin % average, total margin, total cost, and invoiced price, and a tree of rows we can expand and subtotal by client, year, or month. There are 79 | not currently samples of these additional fields or the pivot tables with sample data that have been created, so they're left as an exercise to 80 | the reader in the initial release. You can do whatever other analysis you wish, this is just how we use it so we can have a repeatable/updated 81 | process to get updated information regularly. 82 | 83 | # VERSION HISTORY: 84 | ## V9 is a complete refactor of V8 on 2023-12-05 that changes the following (original version details not tracked): 85 | - Reads in all invoice*.csv files at once to one large array. 86 | - Filters the data using a hash that combines the company name and the Start Period date (SP_ID) as the key. 87 | - Moves the company_name to be a separate field in the output since the key is no longer just company name. 88 | - All final analysis output is based on the actual month in which an individual charge was for, regardless of invoice. 89 | - Loses the name of the invoice file in the output but it isn't relevant for the final analysis. 90 | - Data can be copied to an Excel spreadsheet with pivot table that pulls some date info out and collects invoice amounts. 91 | - Removes the need for the Invoice-More-Summary.ps1 script to do further processing since the above replaces it. 92 | - Removes the redundant assignment of $results to $allResults since there's no looping through CSVs now to collect. 93 | - Removes multiple += array operator usages and increases performance. 94 | 95 | ## V10 is a minor tweak of V9 on 2023-12-06 that changes the following: 96 | - Removes old debug comments to clean up code. 97 | - Sets defaults for parameters after moving invoices around, also changes parameter names. 98 | - Fixes parsing of company name for Nerdio licenses and ignores parent company free licenses. 99 | 100 | ## V11 is an adjustment on 2024-06-13 that changes the following: 101 | - Add third company to the list of companies with Nerdio licenses. 102 | - Refactors the code that updates the company for Nerdio licenses based on the Description field to properly output it in the summary. 103 | - Adds some Verbose output for debugging the Nerdio code activated if the -Verbose flag is passed. 104 | 105 | ## V12 is an adjustment on 2024-07-15 that changes the following for the first public release: 106 | - Moves any client names and client-specific normalization into external Company-Specific-Functions.ps1 file to enable sharing without compromising privacy. 107 | - Documentation added for how to use the script to to the top in markdown format in order to create sharable version. 108 | -------------------------------------------------------------------------------- /Pax8 Invoice Analysis for Azure, AVD, Nerdio/SAMPLE-Company-Specific-Functions.ps1: -------------------------------------------------------------------------------- 1 | # Company-Specific-Functions.ps1 2 | 3 | function Normalize-Row-Data () { 4 | # Normalize company name and ID from one to another to correct some Pax8 billing companies to desired reporting company 5 | 6 | # Normalize company name and ID from one to another if the SKU is Nerdio and thus starts with NIO-INF: 7 | if ($row.sku -like "NIO-INF*") { 8 | Write-Verbose "ORIGINAL: $script:companyName name and $script:companyID ID using separate script!" 9 | if ($row.details -like '*Original Company Name 1*') { 10 | $script:companyName = "New Company 1 To Use" 11 | $script:companyID = "1234567" 12 | } 13 | elseif ($row.details -like '*Original Company Name 2*') { 14 | $script:companyName = "New Company 2 To Use" 15 | $script:ompanyID = "2345678" 16 | } 17 | Write-Verbose "NEW: $script:companyName name and $script:companyID ID using separate script!" 18 | 19 | Write-Verbose "---------------------------------------------------------------" 20 | Write-Verbose "NERDIO SKU: $($row.sku)" 21 | Write-Verbose "NERDIO COMPANY ID: $($row.company_id)" 22 | Write-Verbose "NERDIO ORIG COMPANY: $($row.company_name)" 23 | Write-Verbose "NERDIO COMPANY NAME: $companyName" 24 | Write-Verbose "NERDIO SUBTOTAL: $subtotal" 25 | Write-Verbose "NERDIO COST: $costTotal" 26 | Write-Verbose "NERDIO PERIOD: $startPeriod" 27 | Write-Verbose "NERDIO DESC: $($row.details)" 28 | Write-Verbose "NERDIO DESC: $($row.description)" 29 | } 30 | 31 | # Normalize company name and ID from one to another for any CloudJumpber (CJR-) SKU licenses: 32 | if ($row.sku -like "CJR-*") { 33 | Write-Verbose "ORIGINAL: $script:companyName name and $script:companyID ID using separate script!" 34 | $script:companyName = "Original Company Name 3" 35 | $script:companyID = "3456789" 36 | Write-Verbose "NEW: $script:companyName name and $script:companyID ID using separate script!" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MSP-Scripts 2 | Generic MSP scripts that aren't specific to a Remote Monitoring & Management (RMM) tool, although some of them may run well from an RMM depending on the purpose. The [Windows-Registry-Hardening script](https://github.com/dszp/MSP-Scripts/tree/main/Windows-Registry-Hardening), for example, runs well from an RMM but has nothing RMM-specific in it. 3 | 4 | Scripts that are designed to run from the [NinjaOne](https://www.ninjaone.com) RMM, although they will often run just fine on their own or from another RMM system (with no or minor modifications, depending on the system) are in the [NinjaOne-Scripts](https://github.com/dszp/NinjaOne-Scripts) repository instead. 5 | 6 | ## Licensing and Attribution 7 | Note that some of these scripts I’ve written, and some I’ve modified based on others either initial scripts or contributions; some may even be vendor-provided scripts with minor adjustments to make them more useful. I don’t claim any copyright over scripts or sections of scripts not written by me, and although I haven’t chosen a specific license for my stuff (I may do so), it’s freely usable and modifiable by others (attribution preferred). I’ve attempted to attribute code from others where it’s included. Most scripts are compilations of improvements to small useful projects over time and I’m not aware of any sources who wish to restrict the publication of code that they wrote or contributed to, but if there are objections to the public posting of any of these scripts from other sources, please let me know and I’ll be happy to adjust. You can reach me on Discord at DavidSzp or I’m sure you can probably find other ways to get in touch, I’m not terribly hard to find. -------------------------------------------------------------------------------- /Remove N-Central Agent/README.md: -------------------------------------------------------------------------------- 1 | # [Remove-N-Central-Agent.ps1](./Remove-N-Central-Agent.ps1) 2 | 3 | ## SYNOPSIS 4 | Remove N-able N-Central Windows Agent and related services, both via uninstallation of the agent and related apps (not the Probe) and also via manual cleanup (optional with the `-Clean` parameter) if the uninstall fails. 5 | 6 | ## DESCRIPTION 7 | Uninstalls and optionally cleans/removes the N-able N-Central Windows Agent and related services and folders. It will attempt to run uninstallers for all known agent entries from Add/Remove Programs. 8 | 9 | If the `-Clean` parameter is specified, it will also attempt (after uninstallation) to clean up agent remnants in addition to attempting uninstallation--this includes stopping and disabling related services, removing both the Uninstall and regular application registry entries and disabling the services, attempting to delete the services, and then removing the installation folders and the related folders in `C:\ProgramData`. 10 | 11 | The script _should NOT_ remove N-able Cove backup installations or files/services. 12 | 13 | The script _should_ uninstall but _does NOT_ forcibly clean/remove the Windows Probe service or agent, if it exists. 14 | 15 | The script _does NOT_ remove the registry setting showing the N-central device ID that would be used if the agent were ever reinstalled to map to the same device in the future. This ID should be a harmless artifact in most cases especially if the agent is no longer running on the system, but it could be updated to remove it if desired. 16 | 17 | The script may leave some top-level folders under Program Files, that are empty, or may leave some subfolders that are in use and cannot be removed due to permissions, but it will attempt to remove some of these after a reboot if possible, and any remaining remnants afterwards should not allow the agent to run, even if they aren't completely cleaned up. 18 | 19 | The script _WILL delete_ agent logs and history in ProgramData subfolders. 20 | 21 | The script _WILL attempt to remove_ the Take Control (BeAnywhere) services and folders if they exist and -Clean is specified, but does not attempt any separate uninstallation. 22 | 23 | The script should be run with admin privileges, ideally as SYSTEM, and will quit if it is not. 24 | 25 | Paths and services to clean up are hardcoded into the script under **CONFIG AND SETUP**, and will use the correct system drive for the system but the rest of the paths are hardcoded. The installation folders that any existing services refer to will be added to the cleanup list, if they are different and exist during the run (if `-Clean` is run as part of the initial pass). 26 | 27 | While it can be run manually, it is recommended that the script be run via a different RMM tool, and supports but does not require NinjaRMM Script Variables with the parameter names (as checkboxes) for configuration. 28 | 29 | ## KNOWN ISSUES 30 | *Known issues with version 0.0.1 and 0.0.2:* The services, while they are deleted, are not always fully removed from the system when cleaned if the uninstallations fail. This is a bug that has not been diagnosed/fixed yet, but the services should still be left in the Stopped and Disabled state. 31 | 32 | **This issue has been resolved in 0.0.3** and re-running with `-Clean` should properly delete services. 33 | 34 | ## PARAMETER Clean 35 | Clean up agent remnants in addition to attempting uninstallation. 36 | 37 | ## PARAMETER TestOnly 38 | Test removal of agent and services without actually removing them--will output test info to console instead of making changes. Kind of like a custom -WhatIf dry run without being official. 39 | 40 | ## EXAMPLE 41 | ``` 42 | Remove-N-Central-Agent.ps1 43 | ``` 44 | 45 | ## EXAMPLE 46 | ``` 47 | Remove-N-Central-Agent.ps1 -Clean 48 | ``` 49 | 50 | ## NOTES 51 | **Version 0.0.3** - 2024-03-26 by David Szpunar - Resolution of service deletion bug in cleanup 52 | **Version 0.0.2** - 2024-03-26 by David Szpunar - Update service deletion options 53 | **Version 0.0.1** - 2024-03-25 by David Szpunar - Initial release 54 | -------------------------------------------------------------------------------- /Remove N-Central Agent/Remove-N-Central-Agent.ps1: -------------------------------------------------------------------------------- 1 | <# Remove-N-Central-Agent.ps1 2 | 3 | .SYNOPSIS 4 | Remove N-able N-Central Windows Agent and related services, both via uninstallation of the agent and related apps (not the Probe) and also via manual cleanup (optional with the -Clean parameter) if the uninstall fails. 5 | 6 | .DESCRIPTION 7 | Uninstalls and optionally cleans/removes the N-able N-Central Windows Agent and related services and folders. It will attempt to run uninstallers for all known agent entries from Add/Remove Programs. 8 | 9 | If the -Clean parameter is specified, it will also attempt (after uninstallation) to clean up agent remnants in addition to attempting uninstallation--this includes stopping and disabling related services, removing both the Uninstall and regular application registry entries and disabling the services, attempting to delete the services, and then removing the installation folders and the related folders in C:\ProgramData. 10 | 11 | The script should NOT remove N-able Cove backup installations or files/services. 12 | 13 | The script should uninstall (but does not clean up remants of) the Windows Probe service or agent, if it exists. 14 | 15 | The script does NOT remove the registry setting showing the N-central device ID that would be used if the agent were ever reinstalled to map to the same device in the future. This ID should be a harmless artifact in most cases especially if the agent is no longer running on the system, but it could be updated to remove it if desired. 16 | 17 | The script may leave some top-level folders under Program Files, that are empty, or may leave some subfolders that are in use and cannot be removed due to permissions, but it will attempt to remove some of these after a reboot if possible, and any remaining remnants afterwards should not allow the agent to run, even if they aren't completely cleaned up. 18 | 19 | The script WILL delete agent logs and history in ProgramData subfolders. 20 | 21 | The script WILL attempt to remove the Take Control (BeAnywhere) services and folders if they exist and -Clean is specified, but does not attempt any separate uninstallation. 22 | 23 | The script should be run with admin privileges, ideally as SYSTEM, and will quit if it is not. 24 | 25 | Paths and services to clean up are hardcoded into the script under CONFIG AND SETUP, and will use the correct system drive for the system but the rest of the paths are hardcoded. The installation folders that any existing services refer to will be added to the cleanup list, if they are different and exist during the run (if -Clean is run as part of the initial pass). 26 | 27 | While it can be run manually, it is recommended that the script be run via a different RMM tool, and supports but does not require NinjaRMM Script Variables with the parameter names (as checkboxes) for configuration. 28 | 29 | Service deletion issue with version 0.0.1 and 0.0.2 has been resolved, the script properly deletes services during cleanup, in addition to stopping and disabling. 30 | 31 | .PARAMETER Clean 32 | Clean up agent remnants in addition to attempting uninstallation. 33 | 34 | .PARAMETER TestOnly 35 | Test removal of agent and services without actually removing them--will output test info to console instead of making changes. Kind of like a custom -WhatIf dry run without being official. 36 | 37 | .EXAMPLE 38 | Remove-N-Central-Agent.ps1 39 | 40 | .EXAMPLE 41 | Remove-N-Central-Agent.ps1 -Clean 42 | 43 | .NOTES 44 | Version 0.0.4 - 2024-06-14 by David Szpunar - Update service deletion to clean the N-able Take Control Service and some related folders/registry keys 45 | Version 0.0.3 - 2024-03-26 by David Szpunar - Resolution of service deletion bug in cleanup 46 | Version 0.0.2 - 2024-03-26 by David Szpunar - Update service deletion options 47 | Version 0.0.1 - 2024-03-25 by David Szpunar - Initial release 48 | #> 49 | [CmdletBinding()] 50 | param( 51 | [switch] $Clean, 52 | [switch] $TestOnly 53 | ) 54 | 55 | ### PROCESS NINJRAMM SCRIPT VARIABLES AND ASSIGN TO NAMED SWITCH PARAMETERS 56 | # Get all named parameters and overwrite with any matching Script Variables with value of 'true' from environment variables 57 | # Otherwise, if not a checkbox ('true' string), assign any other Script Variables provided to matching named parameters 58 | $switchParameters = (Get-Command -Name $MyInvocation.InvocationName).Parameters 59 | foreach ($param in $switchParameters.keys) { 60 | $var = Get-Variable -Name $param -ErrorAction SilentlyContinue 61 | if ($var) { 62 | $envVarName = $var.Name.ToLower() 63 | $envVarValue = [System.Environment]::GetEnvironmentVariable("$envVarName") 64 | if (![string]::IsNullOrWhiteSpace($envVarValue) -and ![string]::IsNullOrEmpty($envVarValue) -and $envVarValue.ToLower() -eq 'true') { 65 | # Checkbox variables 66 | $PSBoundParameters[$envVarName] = $true 67 | Set-Variable -Name "$envVarName" -Value $true -Scope Script 68 | } 69 | elseif (![string]::IsNullOrWhiteSpace($envVarValue) -and ![string]::IsNullOrEmpty($envVarValue) -and $envVarValue -ne 'false') { 70 | # non-Checkbox string variables 71 | $PSBoundParameters[$envVarName] = $envVarValue 72 | Set-Variable -Name "$envVarName" -Value $envVarValue -Scope Script 73 | } 74 | } 75 | } 76 | ### END PROCESS SCRIPT VARIABLES 77 | 78 | ##### CONFIG AND SETUP ##### 79 | # These itemss should generally be set via parameters or environment/script variables, but can be manually overridden for testing: 80 | # $TestOnly = $false 81 | # $Clean = $true 82 | # $Verbose = $true 83 | 84 | <# Some of this information was used for interactive troubleshooting and script design but is not a part of the final script, left for reference: 85 | 86 | # $Application = "N-able" 87 | # # $AgentInstall = ("HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall") | ForEach-Object { Get-ChildItem -Path $_ | ForEach-Object { Get-ItemProperty $_.PSPath } | Where-Object { $_.DisplayName -match "$Application" } } 88 | # $AgentInstall = ("HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall") | ForEach-Object { Get-ChildItem -Path $_ | ForEach-Object { Get-ItemProperty $_.PSPath } | Where-Object { $_.Publisher -match "$Application" } } 89 | # $AgentVersion = $AgentInstall.DisplayVersion 90 | # $AgentGUID = $AgentInstall.PSChildName 91 | # $AgentInstall | Format-List 92 | #> 93 | 94 | <# 95 | PREPARE THE LIST OF APPS TO UNINSTALL AND SERVICES TO REMOVE 96 | #> 97 | $AppList = @('Ecosystem Agent', 'Patch Management Service Controller', 'Request Handler Agent', 'File Cache Service Agent') 98 | $MSIList = @('Windows Agent', 'Windows Probe') 99 | 100 | $ServiceList = @('AutomationManagerAgent', 'EcosystemAgent', 'EcosystemAgentMaintenance', 'Windows Agent Service', 'Windows Agent Maintenance Service', 'PME.Agent.PmeService', 'BASupportExpressSrvcUpdater_N_Central', 'BASupportExpressStandaloneService_N_Central', 'N-able Take Control Service') 101 | 102 | # Resolve-Path "$($env:systemdrive)\Program Files*\MspPlatform" 103 | $FolderPathsList = @("$($env:systemdrive)\Program Files*\MspPlatform", 104 | "$($env:systemdrive)\Program Files*\SolarWinds MSP", 105 | "$($env:systemdrive)\Program Files*\MSPEcosystem", 106 | "$($env:systemdrive)\Program Files*\BeAnywhere Support Express", 107 | "$($env:systemdrive)\Program Files*\N-able Technologies\UpdateServerCache", 108 | "$($env:systemdrive)\Program Files*\N-able Technologies\NablePatcheCache", 109 | "$($env:systemdrive)\Program Files*\N-able Technologies\AutomationManagerEngine", 110 | "$($env:systemdrive)\Program Files*\N-able Technologies\Windows Agent", 111 | "$($env:systemdrive)\ProgramData\MspPlatform", 112 | "$($env:systemdrive)\ProgramData\N-able Technologies\AutomationManager", 113 | "$($env:systemdrive)\ProgramData\N-able Technologies\AVDefender", 114 | "$($env:systemdrive)\ProgramData\N-able Technologies\Windows Agent", 115 | "$($env:systemdrive)\ProgramData\N-able Technologies\N-able\AutomationManager", 116 | "$($env:systemdrive)\ProgramData\N-able\AutomationManager", 117 | "$($env:systemdrive)\ProgramData\Solarwinds MSP", 118 | "$($env:systemdrive)\ProgramData\GetSupportService", 119 | "$($env:systemdrive)\ProgramData\GetSupportService_Common", 120 | "$($env:systemdrive)\ProgramData\GetSupportService_Common_N-central", 121 | "$($env:systemdrive)\ProgramData\GetSupportService_N-central", 122 | "$($env:systemdrive)\ProgramData\N-able Technologies", 123 | "$($env:systemdrive)\ProgramData\MSPEcosystem" 124 | ) 125 | 126 | <# 127 | PREPARE THE EMPTY LIST OF FILE PATHS TO LATER REMOVE 128 | #> 129 | $InstallPaths = New-Object System.Collections.Generic.List[System.Object] 130 | 131 | <# 132 | PREPARE THE EMPTY LIST OF FILE PATHS TO LATER REMOVE 133 | #> 134 | $ServicePaths = New-Object System.Collections.Generic.List[System.Object] 135 | 136 | <# 137 | PREPARE THE EMPTY LIST OF REGISTRY PATHS TO LATER REMOVE 138 | #> 139 | $RegistryPaths = New-Object System.Collections.Generic.List[System.Object] 140 | 141 | # $RegistryPaths.Add('HKLM:\SOFTWARE\N-able Technologies') 142 | # $RegistryPaths.Add('HKLM:\SOFTWARE\WOW6432Node\N-able') 143 | $RegistryPaths.Add('HKLM:\SOFTWARE\WOW6432Node\N-able\AM') 144 | $RegistryPaths.Add('HKLM:\SOFTWARE\N-able\AM') 145 | # $RegistryPaths.Add('HKLM:\SOFTWARE\WOW6432Node\N-able Technologies') 146 | $RegistryPaths.Add('HKLM:\SOFTWARE\WOW6432Node\N-able Technologies\Windows Agent') 147 | $RegistryPaths.Add('HKLM:\SOFTWARE\N-able Technologies\Windows Agent') 148 | $RegistryPaths.Add('HKLM:\SOFTWARE\N-able Technologies\Patch Management') 149 | 150 | ###### FUNCTIONS ###### 151 | function Test-IsElevated { 152 | $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() 153 | $p = New-Object System.Security.Principal.WindowsPrincipal($id) 154 | $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) 155 | } 156 | 157 | Function Remove-ItemOnReboot { 158 | # SOURCE: https://gist.github.com/rob89m/6bbea14651396f5870b23f1b2b8e4d0d 159 | [CmdletBinding()] 160 | Param 161 | ( 162 | [Parameter(Mandatory = $true)][string]$Item 163 | ) 164 | END { 165 | # Read current items from PendingFileRenameOperations in Registry 166 | $PendingFileRenameOperations = (Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" -Name PendingFileRenameOperations).PendingFileRenameOperations 167 | 168 | # Append new item to be deleted to variable 169 | $NewPendingFileRenameOperations = $PendingFileRenameOperations + "\??\$Item" 170 | 171 | # Reload PendingFileRenameOperations with existing values plus newly defined item to delete on reboot 172 | Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" -Name "PendingFileRenameOperations" -Value $NewPendingFileRenameOperations 173 | } 174 | } 175 | 176 | function Uninstall-GUID ([string]$GUID) { 177 | # $AgentInstall = ("HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall") | ForEach-Object { Get-ChildItem -Path $_ | ForEach-Object { Get-ItemProperty $_.PSPath } | Where-Object { $_.DisplayName -match "$Application" } } 178 | # $AgentVersion = $AgentInstall.DisplayVersion 179 | # $AgentGUID = $AgentInstall.PSChildName 180 | # 181 | # $UninstallString = "$($AgentInstall.UninstallString) /quiet /norestart" 182 | $UninstallString = "MsiExec.exe /x$AgentGUID /quiet /norestart" 183 | 184 | if ($GUID) { 185 | Write-Host "Uninstalling now via GUID: $GUID" 186 | Write-Verbose "Uninstall String: $UninstallString" 187 | if (!$TestOnly) { 188 | $Process = Start-Process cmd.exe -ArgumentList "/c $UninstallString" -Wait -PassThru 189 | if ($Process.ExitCode -eq 1603) { 190 | Write-Host "Uninstallation attempt failed with error code: $($Process.ExitCode). Please review manually." 191 | Write-Host "Hint: This exit code likely requires the system to reboot prior to installation." 192 | } 193 | elseif ($Process.ExitCode -ne 0) { 194 | Write-Host "Uninstallation attempt failed with error code: $($Process.ExitCode). Please review manually." 195 | } 196 | else { 197 | Write-Host "Uninstallation attempt completed." 198 | } 199 | return $($Process.ExitCode) 200 | } 201 | else { 202 | Write-Host "TEST ONLY: No uninstallation attempt was made." 203 | return 0 204 | } 205 | } 206 | else { 207 | Write-Host "Pass a GUID to the function." 208 | return $false 209 | } 210 | } 211 | 212 | function Uninstall-App ($Agent) { 213 | $QuietUninstall = $Agent.QuietUninstallString 214 | if ([string]::IsNullOrWhiteSpace($QuietUninstall)) { 215 | Write-Host "No QuietUninstall string, skipping silent uninstall for" $Agent.DisplayName 216 | return $false 217 | } 218 | $UninstallString = $Agent.UninstallString + " /SILENT /VERYSILENT /SUPPRESSMSGBOXES" 219 | 220 | if ($Agent) { 221 | Write-Host "Uninstalling now via Inno Setup Silent Removal:" $Agent.DisplayName 222 | Write-Host "Uninstall String: $UninstallString" 223 | if (!$TestOnly) { 224 | $Process = Start-Process cmd.exe -ArgumentList "/c $UninstallString" -Wait -PassThru 225 | if ($Process.ExitCode -ne 0) { 226 | Write-Host "Uninstallation attempt failed with error code: $($Process.ExitCode). Please review manually." 227 | } 228 | else { 229 | Write-Host "Uninstallation attempt completed for" $Agent.DisplayName 230 | return 0 231 | } 232 | return $($Process.ExitCode) 233 | } 234 | else { 235 | Write-Host "TEST ONLY: No uninstallation attempt was made." 236 | return 0 237 | } 238 | } 239 | else { 240 | Write-Host "Pass an uninstall registry object to the function." 241 | return 1 242 | } 243 | } 244 | 245 | 246 | 247 | Function Get-ServiceStatus ([string]$Name) { 248 | (Get-Service -Name $Name -ErrorAction SilentlyContinue).Status 249 | } 250 | 251 | Function Stop-RunningService ($svc) { 252 | Write-Verbose "Checking if $($svc.Name) service is running to STOP" 253 | # If ( $(Get-ServiceStatus -Name $Name) -eq "Running" ) { 254 | If ( $svc.Status -eq "Running" ) { 255 | Write-Host "Stopping : $($svc.Name) service" 256 | if (!$TestOnly) { 257 | # Stop-Service -Name $Svc -Force 258 | $svc | Stop-Service -Force 259 | } 260 | else { 261 | Write-Host "TEST ONLY: Not stopping $Name service" 262 | } 263 | } 264 | else { 265 | Write-Verbose "The $($svc.Name) service is not running, not stopping it!" 266 | } 267 | } 268 | 269 | Function Disable-Service ($svc) { 270 | If ( $svc ) { 271 | Write-Host "Disabling : $($svc.Name) service" 272 | if (!$TestOnly) { 273 | # Set-Service $Svc -StartupType Disabled 274 | $svc | Set-Service -StartupType Disabled 275 | } 276 | else { 277 | Write-Host "TEST ONLY: Not disabling $($svc.Name) service" 278 | } 279 | } 280 | else { 281 | Write-Verbose "The $($svc.Name) service doesn't exist, not disabling it!" 282 | } 283 | } 284 | 285 | Function Remove-StoppedService ($svc) { 286 | If ( $svc ) { 287 | If ( $svc.Status -eq "Stopped" ) { 288 | Write-Host "Deleting : $($svc.Name) service" 289 | if (!$TestOnly) { 290 | Stop-Process -Name $($svc.Name) -Force -ErrorAction SilentlyContinue 291 | sc.exe delete $($svc.Name) 292 | Remove-Item "HKLM:\SYSTEM\CurrentControlSet\Services\$($svc.Name)" -Force -Recurse -ErrorAction SilentlyContinue 293 | } 294 | else { 295 | Write-Host "TEST ONLY: Not deleting $Name service" 296 | } 297 | } 298 | else { 299 | Write-Host "The $($svc.Name) service is not stopped, not deleting it!" 300 | } 301 | } 302 | Else { 303 | Write-Verbose "Not Found to Remove: $($svc.Name) service" 304 | } 305 | } 306 | 307 | Function Remove-File-Path ([string]$Path) { 308 | Write-Host "Deleting folder if it exists: $Path" 309 | $FolderPath = Resolve-Path -Path $Path.Trim('"') -ErrorAction SilentlyContinue 310 | if (![string]::IsNullOrEmpty($FolderPath) -and (Test-Path $FolderPath)) { 311 | Write-Host "Removing folder: $FolderPath" 312 | if (!$TestOnly) { 313 | try { 314 | Remove-Item -Path $FolderPath -Recurse -Force -ErrorAction Stop 315 | } 316 | catch { 317 | Write-host "Error deleting folder '$FolderPath\', adding to delete on reboot list." 318 | Remove-ItemOnReboot -Item "$FolderPath\" 319 | } 320 | } 321 | else { 322 | Write-Host "TEST ONLY: Not removing $FolderPath" 323 | } 324 | } 325 | else { 326 | Write-Verbose "Not found and thus not deleting: $Path" 327 | } 328 | } 329 | 330 | Function Remove-Registry-Path ([string]$Path) { 331 | Write-Verbose "Deleting registry path: $Path" 332 | $KeyPath = Resolve-Path $Path -ErrorAction SilentlyContinue 333 | if (![string]::IsNullOrEmpty($KeyPath) -and (Test-Path $KeyPath)) { 334 | Write-Host "Removing key $KeyPath" 335 | if (!$TestOnly) { 336 | Remove-Item -Path $KeyPath -Recurse -Force 337 | } 338 | else { 339 | Write-Host "TEST ONLY: Not removing $KeyPath" 340 | } 341 | } 342 | else { 343 | Write-Verbose "Not found and thus not deleting: ${Path}" 344 | } 345 | } 346 | 347 | function Remove-Agent { 348 | foreach ($service in $ServiceList) { 349 | Write-Host "`nGetting Service $service" 350 | $ServiceObj = Get-Service $service -ErrorAction SilentlyContinue 351 | if (($ServiceObj)) { 352 | $SvcInfo = Get-WmiObject win32_service | Where-Object { $_.Name -eq "$service" } | Select-Object Name, DisplayName, State, StartMode, PathName 353 | Write-Host "STATE: $($SvcInfo.State) MODE: $($SvcInfo.StartMode) `tSERVICE: $($SvcInfo.DisplayName) '$($SvcInfo.Name)'" 354 | Write-Host "PATH: $($SvcInfo.PathName)" 355 | 356 | $SvcPath = Split-Path -Path $($SvcInfo.PathName).Trim('"') -Parent 357 | $ServicePaths.Add($SvcPath) 358 | 359 | Stop-RunningService $ServiceObj 360 | Disable-Service $ServiceObj 361 | $ServiceObj = Get-Service $service -ErrorAction SilentlyContinue 362 | Remove-StoppedService $ServiceObj 363 | } 364 | else { 365 | Write-Host "Service $service not found." 366 | } 367 | } 368 | 369 | Write-Host 370 | 371 | foreach ($Folder in $FolderPathsList) { 372 | Remove-File-Path $Folder 373 | } 374 | 375 | foreach ($Folder in $InstallPaths) { 376 | Remove-File-Path $Folder 377 | } 378 | 379 | foreach ($Folder in $ServicePaths) { 380 | Remove-File-Path $Folder 381 | } 382 | 383 | foreach ($Key in $RegistryPaths) { 384 | Remove-Registry-Path $Key 385 | } 386 | } 387 | 388 | ##### BEGIN SCRIPT ##### 389 | 390 | # If not elevated error out. Admin priveledges are required to uninstall software 391 | if (-not (Test-IsElevated)) { 392 | Write-Error -Message "Access Denied. Please run with Administrator privileges." 393 | exit 1 394 | } 395 | 396 | foreach ($app in $MSIList) { 397 | $AgentInstall = ("HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall") | ForEach-Object { Get-ChildItem -Path $_ | ForEach-Object { Get-ItemProperty $_.PSPath } | Where-Object { $_.DisplayName -match "$app" } } 398 | $AgentGUID = $AgentInstall.PSChildName 399 | 400 | if ($AgentInstall) { 401 | $InstallPaths.Add($AgentInstall.InstallLocation) 402 | $RegistryPaths.Add($AgentInstall.PSPath) 403 | Write-Host "`nUninstalling app '$app' using GUID: " $AgentGUID 404 | if ((Uninstall-GUID $AgentGUID) -eq 0) { 405 | Write-Host "Successfully uninstalled '$app' via MSI command using GUID $AgentGUID" 406 | } 407 | } 408 | else { 409 | Write-Verbose "No installation entry found to uninstall '$app' via MSI command." 410 | } 411 | } 412 | 413 | foreach ($app in $AppList) { 414 | $AgentInstall = ("HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall") | ForEach-Object { Get-ChildItem -Path $_ | ForEach-Object { Get-ItemProperty $_.PSPath } | Where-Object { $_.DisplayName -match "$app" } } 415 | $AgentGUID = $AgentInstall.PSChildName 416 | 417 | if ($AgentInstall) { 418 | $InstallPaths.Add($AgentInstall.InstallLocation) 419 | $RegistryPaths.Add($AgentInstall.PSPath) 420 | Write-Host "`nUninstalling app '$app' using Inno Setup Quiet Removal." 421 | if ((Uninstall-App $AgentInstall) -eq 0) { 422 | Write-Host "Successfully uninstalled '$app' silently via Inno Setup Quiet Removal." 423 | } 424 | else { 425 | Write-Host "Unable to uninstall '$app' silently via Inno Setup Quiet Removal." 426 | } 427 | } 428 | else { 429 | Write-Verbose "No installation entry found to uninstall '$app' silently via Inno Setup command." 430 | } 431 | } 432 | 433 | Write-Host "Folder Paths:" 434 | $InstallPaths 435 | Write-Host "Registry Paths:" 436 | $RegistryPaths 437 | 438 | 439 | if ($Clean) { 440 | Write-Host "`nAttempting to clean up agent remnants..." 441 | Remove-Agent $AgentInstall 442 | 443 | Write-Host "`nService Folder Paths after Agent Removal:" 444 | $ServicePaths 445 | } 446 | -------------------------------------------------------------------------------- /ScreenConnect Self-Hosted LetsEncrypt Certs/BindNewScreenConnectCert.cmd: -------------------------------------------------------------------------------- 1 | @REM PASS THESE ARGUMENTS: cert-thumbprint certbinding-ip:port 2 | @REM cert-thumbprint: win-acme's {CertThumbprint} variable 3 | @REM certbinding-ip:port: the IP:port value of existing ScreenConnect 4 | @REM cert in the output of this command: 5 | @REM netsh http show sslcert 6 | @ECHO off 7 | echo "Installing ScreenConnect TLS Certificate" 8 | net stop "ScreenConnect Web Server" 9 | 10 | ECHO Now installing cert %1 11 | netsh http delete sslcert ipport=%2 12 | netsh http add sslcert ipport=%2 certhash=%1 appid="{00000000-0000-0000-0000-000000000000}" 13 | 14 | net start "ScreenConnect Web Server" 15 | -------------------------------------------------------------------------------- /ScreenConnect Self-Hosted LetsEncrypt Certs/README.md: -------------------------------------------------------------------------------- 1 | # Set up self-hosted ScreenConnect LetsEncrypt TLS certificates 2 | 3 | ## Context and Background 4 | 5 | When using a self-hosted ConnectWise ScreenConnect server, if you don't want to manually renew the TLS/SSL certificates annually, you must set up TLS certificates using LetsEncrypt. This is a feature that ConnectWise has rejected including, but the application does not use IIS directly, so the certificate that's used must be manually bound to the application initially and after renewal. This script is designed for Windows and is NOT relevant for Linux or macOS, where older versions of ScreenConnect ran and where there is more documentation available online for automating this process (and ConnectWise doesn't support SSO on any servers except Windows, which is important for some people). 6 | 7 | ### Introduction and Overview 8 | 9 | There were too many possible options, libraries, and methods to try but no simple process with a very straightforward installation script that was tested for use on modern Windows versions with the default ScreenConnect web server configuration that didn't involve proxies or third parties like CloudFlare, so I assembled this process that requires the very well-written, easy-to-use, and frequently updated win-acme tool and a tiny script to install the certificate. Hopefully this provides the push to stop renewing certificates manually for ScreenConnect! 10 | 11 | ## What this is not 12 | 13 | This process assumes you already have an operational [ScreenConnect](https://screenconnect.com) installation on your own self-hosted server, and that it's already configured with a valid TLS certificate, perhaps issued by RapidSSL or any other certificate authority where you buy certificates and manually retrieve and install them, but that you'd like to switch to using LetsEncrypt certificates instead. 14 | 15 | This process assumes you are having LetsEncrypt configured for TLS, are using the built-in web server and not proxying the web server through a third party like CloudFlare or yourself using nginx or Caddy, so it doesn't walk you through that process. It also assumes you have locked down the TLS settings yourself and validated it using a service like [Qualys SSLLabs](https://www.ssllabs.com/ssltest/) in order to ensure only modern and secure TLS configurations are used. 16 | 17 | ## Alternate Solutions 18 | 19 | We will assume you will not use a self-signed certificate, which is not recommended as it is not secure and is subject to being revoked. This is not a real option for a public server. 20 | 21 | 1. Use a third-party tool like CertifyTheWeb to obtain and renew a certificate from LetsEncrypt. This should work and is well documented, but as of this writing costs approximately $60 per year, which is substantially more than a basic RapidSSL certificate (though you'd have to spend the time renewing it manually). 22 | 2. Create your own PowerShell or command-line based script to obtain and renew a certificate from LetsEncrypt and apply it to ScreenConnect. There are various PowerShell modules for ACME v2 that would work; this solution is relatively close but it uses a third-party free tool called [win-acme](https://www.win-acme.com/) to obtain and renew the certificate, calling the Command Line script provided here to remove the old and install the new certificate for ScreenConnect use. 23 | 24 | ## This Solution 25 | 26 | ### win-acme and installation script 27 | The script in this repository, `BindNewScreenConnectCert.cmd` (the installation script that win-acme runs after obtaining the certificate) should be placed into a folder with the extracted win-acme zip file contents. From the [win-acme](https://www.win-acme.com/) website, on your Windows server where ScreenConnect is running, place the extracted win-acme zip file contents in a permanent location. You may wish to create a folder like C:\certs to hold the files, or place it in an existing location. I'm using `C:\certs\win-acme-pluggable` in this example and you're welcome to copy mine or choose your own. The executable that matters is `C:\certs\win-acme-pluggable\wacs.exe` or the same file inside the extracted zip on your system. 28 | 29 | ### Validating for certificate to be issued 30 | Configuring win-acme to obtain a certificate and validate properly is left up to you, but their documentation is relatively good. If you've run certbot on Linux it's not terribly different. Validation can be completed via multiple web-based or DNS-based method. If web-based, you'll need to allow port 80 for HTTP access to the server to handle validation, all the way through the firewall. win-acme has a plugin system for many DNS providers that can validate with DNS instead (including Microsoft Azure DNS, CloudFlare, and many others). 31 | 32 | ### Determine the bound IP and port of ScreenConnect server 33 | If you run this command from an elevated command prompt, it will display the TLS certificates that are mapped to listening IP:port combinations: 34 | 35 | `netsh http show sslcert` 36 | 37 | The first field should be named "IP/port" and should be in a global "listen on all interfaces" format like `0.0.0.0:443` or a specific IP (public or private) like `10.0.0.2:443`. As noted above, you need ScreenConnect already functional and listening or to figure this out first. Some of the potential commands and screenshots of sample output are listed at [Replace/renew your SSL certificate in ConnectWise OnPrem](https://nadavsvirsky.medium.com/replace-renew-your-ssl-certificate-in-connectwise-onprem-82fc15352227). Gather the IP:port string from the above command and record it for the next step. 38 | 39 | If you need to set this up for the first time, there are instructions at [ConnectWise Control (ScreenConnect) On-Premise SSL Installation Woes… Here’s the Secret to Using an Alternate Port (not 443), Working With SSL](https://asheroto.medium.com/screenconnect-on-premise-ssl-installation-woes-heres-the-secret-to-get-an-alternate-port-working-931f240ced92) although you can simply use the default port 443, but that's up to you. (Note the article recommends RapidSSL certificates and suggests the benefit is not renewing every 90 days; we are automating this process so that's not ever necessary but the manual setup steps are similar and useful! You don't have to use the ScreenConnectSSLConfigurator they recommend if you just want to get a certificate with win-acme in the first place which will be used in the same way, and win-acme handles all public and private keys and CSRs in addition to renewals.) The section starting "To show existing bindings" has the more useful information in this article, along with the next "Binding the Certificate — Method 1" section; Method 2 uses the ScreenConnectSSLConfigurator again but shouldn't be necessary. 40 | 41 | ### Run wacs.exe and obtain a new certificate 42 | Open an elevated command prompt or PowerShell prompt (either is fine) and change to the folder you extracted the win-acme zip file contents to, then run `.\wacs.exe` from the command prompt. It will launch a menu and let you walk through obtaining a new certificate, using the `M` option to Create certificate (full options). 43 | 44 | Walk through the wizard to obtain a new certificate. Choose your own validation options to match what you wish to use and have available. The "Store" you choose should be "Windows Certificate Store (Local Computer)" and when prompted for how you would like to store the certificate, choose "2: [My] - General computer store (for Exchange/RDS)" as the option. If you want to store the certificate a second way, choose a second set of options but this should not be necessary and you can choose "5: No (additional) store steps" (the default). 45 | 46 | For the Installation step, choose "2: Start external script or program" and enter the full path to the BindNewScreenConnectCert.cmd script and press Enter, for example in the folder above: 47 | 48 | `C:\certs\win-acme-pluggable\BindNewScreenConnectCert.cmd` 49 | 50 | You'll then be prompted for Parameters. The script requires two parameters, the first is the thumbprint of the certificate to bind. The second is the IP:port to bind to (which it will unbind first). Enter the placeholder variable and the IP:port string all on one line but separate by a space as the Parameters value, exactly like this with the curly braces (customize only the IP:port portion), then press Enter: 51 | 52 | `{CertThumbprint} 0.0.0.0:443` 53 | 54 | Choose 3 to perform no additional installation steps and press Enter. The certificate will be obtained, assuming no errors, saved to the Local Computer Personal Certificates, then the installation script will be called by win-acme and the script will unbind the existing certificate (if any), bind the new one by thumbprint (both using `netsh` commands). **Warning: This process will stop the ScreenConnect Web Server service! After rebinding the new certificate, it will start it again. However, connections will be interrupted!** 55 | 56 | By default, Task Scheduler will have a task added by win-acme to run daily to attempt certificate renewal. However, certificates won't be renewed until 55 days have elapsed by default. The `settings.json` file can be edited in the folder alongside `wacs.exe` and the settings are well-documented on the win-acme website. You may wish to provide different schedule times (perhaps not during the day, since the process stops the ScreenConnect Web Server service and restarts it after rebinding). 57 | 58 | You can exit the win-acme menus now and run `netsh http show sslcert` to verify the new certificate is bound properly. 59 | 60 | ### Editing the certificate 61 | You can re-run `wacs.exe` and choose Manage Renewals to walk through the reviewing or editing any of the settings you set above. After editing, the certificate will be reinstalled including reloading the ScreenConnect service! Note that by default the renewal will use the cached certificate and will not reach out to LetsEncrypt if it's been less than a day, by default, to avoid rate-limiting. 62 | 63 | Renewals should run automatically, but you can edit `settings.json` to configure an SMTP server and email details to send failure and, optionally, success notifications to you via email, or you can monitor the expiration externally with a tool like UptimeRobot or others that will alert you if the renewal fails with enough time to remediate the issue. 64 | 65 | ### Examining the command line generated for renewals 66 | The Renewals submenu in `wacs.exe` also has an "L" option that will display the command line you can run to renew the certificate manually; if nothing else this is nice to review what the command line options being used are, and see the script call and parameters that are set. It also displays the next renewal attempt date of all certificates being managed for review. 67 | 68 | ### Reviewing the script run in Event Log 69 | win-acme does a great job of logging, to its own log and to the Windows Application Event Log. If you want to see the actual output of the `BindNewScreenConnectCert.cmd` script, you can open the Windows Event Log and search for Event ID 7703, the source will be "win-acme" if you'd like to set a filter. The output of the script will be in the "Message" field of the event on the General tab of this event ID when it's logged. You should see the service stopping, the binding deleted, re-added, and the service told to start again. 70 | 71 | ## Summary 72 | These instructions could likely be more concise and clearer, possibly with some screenshots, but this is the first pass. If you have any suggestions for improvement, please let me know! Pull requests are also welcome. Hope this is helpful! 73 | 74 | # References 75 | 76 | A few links that were helpful in my research, not mentioned above: 77 | 78 | - [win-acme documentation for installation scripts](https://www.win-acme.com/reference/plugins/installation/script) 79 | - [official ScreenConnect docs on updating SSL cert](https://docs.connectwise.com/ConnectWise_ScreenConnect_Documentation/On-premises/Advanced_setup/SSL_certificate_installation/Install_and_bind_an_SSL_certificate_on_a_Windows_server) 80 | - [Installing an SSL certificate manually on Windows](https://docs.connectwise.com/ConnectWise_ScreenConnect_Documentation/On-premises/Advanced_setup/SSL_certificate_installation/Install_and_bind_an_SSL_certificate_on_a_Windows_server) 81 | - [official ScreenConnect docs on the SSL Configurator tool](https://docs.connectwise.com/ConnectWise_ScreenConnect_Documentation/On-premises/Advanced_setup/SSL_certificate_installation/SSL_Configurator) -------------------------------------------------------------------------------- /ScreenConnect Server/Upgrade-ScreenConnect-Server.ps1: -------------------------------------------------------------------------------- 1 | <# Upgrade-ScreenConnect-Server.ps1 2 | Run this PowerShell script from an elevated PowerShell prompt to upgrade your ScreenConnect server to the latest version. 3 | 4 | Before running, confirm the $Downloads folder path exists (configured below) and optionally contains the latest ScreenConnect installer. 5 | 6 | The script will first check the online download page for ScreenConnect and attempt to verify the newest verison. If there is a newer version available 7 | to download, it will prompt you to download the latest version. If you choose not to, it will continue with the latest installer in the $Downloads folder, 8 | if one already exists. 9 | 10 | Note that the download page parsing may or may not be accurate and has only been tested with one version avialble, for Windows. If parsing fails, 11 | please report the issue to the author. You should be able to install using the latest installer in the $Downloads folder regardless, if one exists. 12 | 13 | Use the -SkiDownload parameter to skip the download prompt or cloud version check and only use locally downloaded installers, if any. 14 | 15 | Downloads are located at https://www.screenconnect.com/Download online. NOTE: ScreenConnect Client is NOT installed as part of this script!! This is for the server itself! 16 | 17 | Run this PowerShell script from an elevated PowerShell prompt to upgrade your ScreenConnect server to the latest version. Will prompt you before proceeding, but otherwise will run silently. 18 | 19 | Existing ScreenConnect server must already be installed. Version numbers will be verified and the upgrade will only be performed if the installer is for a newer version than the installation. 20 | 21 | Install the PowerShell module "MSI" using "Install-Module MSI" if it is not already installed (you will a runtime error if it's not installed). 22 | 23 | Use the switch -Force to download (if necessary) and install without prompting, assuming newer installer than installed version. 24 | 25 | Version 0.0.1 - 2024-02-24 - Initial version by David Szpunar 26 | Version 0.0.2 - 2025-05-01 - Updated to incorporate version checking and download functionality directly in this script. 27 | #> 28 | param( 29 | [switch] $Force, 30 | [switch] $SkipDownload 31 | ) 32 | #Requires -Modules MSI 33 | 34 | ### CONFIG 35 | # Define where the ScreenConnect MSI files are located, defaults to current user's Downloads folder. 36 | # Only the most recently modified MSI file with name starting with ScreenConnect_ will be used. 37 | $Downloads = "~\Downloads" 38 | ### END CONFIG 39 | 40 | #region Functions 41 | function Get-LatestScreenConnectVersion { 42 | [CmdletBinding()] 43 | param() 44 | 45 | try { 46 | # Download the ScreenConnect download page 47 | $url = "https://www.screenconnect.com/Download" 48 | $response = Invoke-WebRequest -Uri $url -UseBasicParsing 49 | $content = $response.Content 50 | 51 | # Look for the download URL of the stable release version 52 | $downloadUrlPattern = 'https://[^"]+ScreenConnect_[\d\.]+_Release\.msi' 53 | if ($content -match $downloadUrlPattern) { 54 | $downloadUrl = $matches[0] 55 | $fileName = $downloadUrl.Split('/')[-1] 56 | 57 | # Extract version from filename 58 | $version = "Unknown" 59 | if ($fileName -match 'ScreenConnect_(\d+\.\d+\.\d+\.\d+)') { 60 | $version = $matches[1] 61 | } 62 | 63 | # Extract information directly using the HTML structure provided by the user 64 | # Look for the row containing this URL 65 | $rowPattern = ']*>(.*?)' 66 | if ($content -match $rowPattern) { 67 | $rowContent = $matches[1] 68 | 69 | # Extract the column values using the known structure 70 | $sizePattern = '