tools/Send-DiscoveryOutput.ps1
|
<# .SYNOPSIS Sends or uploads a Discover-WindowsServer output archive (the zip under archive\, or any file you point it at) somewhere the requester can get it - email or an HTTPS upload. .DESCRIPTION Deliberately separate from the discovery engine itself: Discover-WindowsServer.ps1 stays strictly read-only and writes only inside its own output folder (see the repo's safety contract in README.md). This script is an explicit, opt-in step you run AFTER a discovery completes, only when you choose to. It never runs on its own and the core engine never calls it. Four delivery methods, each a peer with its own native API (not one generic SMTP path wearing different names) - request schemas verified against each provider's own docs: Smtp Any standard SMTP server (host/port/credential/TLS) via System.Net.Mail. Smtp2Go SMTP2GO's HTTP API (api.smtp2go.com/v3/email/send). SendGrid SendGrid's HTTP v3 Mail Send API (api.sendgrid.com/v3/mail/send). Postal A self-hosted Postal server's HTTP API (<your-host>/api/v1/send/message) - "Custom Postal Server" because, unlike the other two, you supply the host. Upload A plain HTTPS PUT (or POST) of the file to any URL you provide - e.g. a pre-signed S3/Azure Blob upload URL, or an internal file-drop endpoint. Credentials (SMTP password, or the API key for Smtp2Go/SendGrid/Postal) are never written into the discovery output and never appear in this repo. Resolution order per call: 1. An explicit -Credential you pass in that run. 2. A previously saved credential (DPAPI-encrypted via Export-Clixml, under %APPDATA%\Discover-WindowsServer\credentials\ - decryptable only by the same Windows user on the same machine; useless if copied elsewhere). 3. An interactive prompt (Get-Credential) - offered to be saved with -SaveCredential, never persisted otherwise. For the three API-key providers, the "credential" is a PSCredential whose password field holds the key (username is ignored) - one storage mechanism for all four methods. .PARAMETER ZipPath The file to send - normally the .zip under <output folder>\archive\ (Discover-WindowsServer_<host>_<stamp>.zip). .PARAMETER Method Smtp, Smtp2Go, SendGrid, Postal, or Upload. .PARAMETER CredentialName Logical name the credential is stored/retrieved under (e.g. 'SendGrid', 'ClientSmtp'). Required for Smtp/Smtp2Go/SendGrid/Postal unless -Credential is supplied inline. .EXAMPLE .\tools\Send-DiscoveryOutput.ps1 -ZipPath C:\Temp\...\archive\Discover-WindowsServer_SRV01_20260101_120000.zip -Method SendGrid ` -CredentialName SendGrid -From reports@msp.com -To client@customer.com -SaveCredential .EXAMPLE .\tools\Send-DiscoveryOutput.ps1 -ZipPath C:\Temp\...\archive\Discover-WindowsServer_SRV01_20260101_120000.zip -Method Upload ` -UploadUrl 'https://storage.example.com/dropbox/run.zip?sig=...' #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialName', Justification = 'a credential-store key name, not a secret')] param( [string]$ZipPath, [ValidateSet('Smtp', 'Smtp2Go', 'SendGrid', 'Postal', 'Upload')] [string]$Method, # Email - common [string]$From, [string[]]$To, [string]$Subject = 'Discover-WindowsServer output', [string]$Body = 'Discovery output is attached.', # Smtp (generic) [string]$SmtpServer, [int]$SmtpPort = 587, [bool]$UseSsl = $true, # Postal only: your self-hosted server's base URL, e.g. https://postal.example.com [string]$PostalServerUrl, # Upload [string]$UploadUrl, [ValidateSet('Put', 'Post')] [string]$UploadMethod = 'Put', # Credential resolution (see .DESCRIPTION) [string]$CredentialName, [pscredential]$Credential, [switch]$SaveCredential, [int]$MaxAttachmentSizeMB = 20 ) $ErrorActionPreference = 'Stop' #region Credential storage (DPAPI via Export-Clixml; see .DESCRIPTION) -------- function Get-DiscoveryCredentialStorePath { <# Where a named credential lives on disk, encrypted. Creates the folder if needed. -BaseDirectory defaults to the real per-user store; tests override it to a disposable directory so they never touch this machine's actual %APPDATA%. #> param( [Parameter(Mandatory)][string]$Name, [string]$BaseDirectory = (Join-Path $env:APPDATA 'Discover-WindowsServer\credentials') ) if (-not (Test-Path -LiteralPath $BaseDirectory)) { New-Item -ItemType Directory -Path $BaseDirectory -Force | Out-Null } $safeName = ($Name -replace '[^\w.-]', '_') Join-Path $BaseDirectory ("{0}.credential.xml" -f $safeName) } function Get-StoredDeliveryCredential { <# Returns the stored PSCredential for $Name, or $null if none is saved. #> param([Parameter(Mandatory)][string]$Name, [string]$BaseDirectory) $path = if ($BaseDirectory) { Get-DiscoveryCredentialStorePath -Name $Name -BaseDirectory $BaseDirectory } else { Get-DiscoveryCredentialStorePath -Name $Name } if (-not (Test-Path -LiteralPath $path)) { return $null } try { return (Import-Clixml -LiteralPath $path) } catch { return $null } } function Save-DeliveryCredential { <# Persists a credential DPAPI-encrypted, readable only by this user on this machine. #> param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][pscredential]$Credential, [string]$BaseDirectory) $path = if ($BaseDirectory) { Get-DiscoveryCredentialStorePath -Name $Name -BaseDirectory $BaseDirectory } else { Get-DiscoveryCredentialStorePath -Name $Name } $Credential | Export-Clixml -LiteralPath $path -Force } function Remove-DeliveryCredential { param([Parameter(Mandatory)][string]$Name, [string]$BaseDirectory) $path = if ($BaseDirectory) { Get-DiscoveryCredentialStorePath -Name $Name -BaseDirectory $BaseDirectory } else { Get-DiscoveryCredentialStorePath -Name $Name } if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force } } function Get-OrPromptDeliveryCredential { <# Resolution order: explicit -Credential, then the DPAPI store, then an interactive prompt. Only ever writes to disk when -SaveCredential is passed. #> param( [Parameter(Mandatory)][string]$Name, [pscredential]$Credential, [switch]$SaveCredential, [string]$PromptUserName = 'ApiKey', [string]$PromptMessage, [string]$BaseDirectory ) if ($Credential) { if ($SaveCredential) { Save-DeliveryCredential -Name $Name -Credential $Credential -BaseDirectory $BaseDirectory } return $Credential } $stored = Get-StoredDeliveryCredential -Name $Name -BaseDirectory $BaseDirectory if ($stored) { return $stored } if (-not $PromptMessage) { $PromptMessage = "Enter the credential for '$Name' (username is ignored for API-key providers - put the key in the password field)" } $cred = Get-Credential -UserName $PromptUserName -Message $PromptMessage if (-not $cred) { throw "No credential supplied for '$Name'." } if ($SaveCredential) { Save-DeliveryCredential -Name $Name -Credential $cred -BaseDirectory $BaseDirectory } return $cred } #endregion #region Shared helpers --------------------------------------------------------- function Test-DiscoveryAttachmentSize { <# Throws a clear error before attempting to email a file too large for the transport. #> param([Parameter(Mandatory)][string]$Path, [int]$MaxSizeMB = 20) if (-not (Test-Path -LiteralPath $Path)) { throw "File not found: $Path" } $sizeMB = (Get-Item -LiteralPath $Path).Length / 1MB if ($sizeMB -gt $MaxSizeMB) { throw ("'{0}' is {1:N1} MB, over the {2} MB email limit (-MaxAttachmentSizeMB). Use -Method Upload for a file this size." -f $Path, $sizeMB, $MaxSizeMB) } } function ConvertTo-Base64File { param([Parameter(Mandatory)][string]$Path) [Convert]::ToBase64String([System.IO.File]::ReadAllBytes($Path)) } function Find-DiscoveryRunZip { <# The run's archive zip, or $null. The engine names it Discover-WindowsServer_<host>_<stamp>.zip (New-DemoEngagement and the smoke tests use run.zip), so match any .zip rather than one fixed name - a hardcoded 'run.zip' here once meant delivery never found a real run's archive. #> param([Parameter(Mandatory)][string]$OutputPath) $archiveDir = Join-Path $OutputPath 'archive' if (-not (Test-Path -LiteralPath $archiveDir)) { return $null } $zip = Get-ChildItem -LiteralPath $archiveDir -Filter '*.zip' -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($zip) { return $zip.FullName } return $null } function Enable-DiscoveryTls12 { <# Windows PowerShell 5.1 on Server 2012 R2/2016 defaults to Ssl3|Tls (confirmed live on LABSRV12/LABSRV16), which SendGrid, SMTP2GO, Microsoft 365 SMTP and GitHub all refuse. Adds TLS 1.2 without removing anything. SystemDefault (0) is left alone: the OS already negotiates 1.2/1.3 there, and OR-ing in Tls12 would pin it to 1.2 and drop TLS 1.3. #> $current = [int][Net.ServicePointManager]::SecurityProtocol if ($current -ne 0 -and -not ($current -band 3072)) { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]($current -bor 3072) } } function Assert-DiscoveryHttpsUrl { <# Client discovery data (and Postal's API key header) must never travel over plain HTTP. #> param([Parameter(Mandatory)][AllowEmptyString()][string]$Url, [Parameter(Mandatory)][string]$Name) $parsed = $null if (-not [Uri]::TryCreate($Url, [UriKind]::Absolute, [ref]$parsed) -or $parsed.Scheme -ne 'https') { throw ("{0} must be an absolute https:// URL - refusing to send client discovery data over '{1}'." -f $Name, $Url) } } #endregion #region Smtp (generic + the base every real SMTP relay, including SMTP2GO's own, can use) ----- function Send-ViaSmtpClient { <# Thin wrapper around System.Net.Mail so tests can Mock this exact function instead of needing to intercept .NET object construction (Pester's Mock only works on commands). #> param( [Parameter(Mandatory)][string]$SmtpServer, [int]$Port = 587, [bool]$UseSsl = $true, [pscredential]$Credential, [Parameter(Mandatory)][string]$From, [Parameter(Mandatory)][string[]]$To, [string]$Subject = '', [string]$Body = '', [string]$AttachmentPath ) $client = [System.Net.Mail.SmtpClient]::new($SmtpServer, $Port) $msg = [System.Net.Mail.MailMessage]::new() $attachment = $null try { $client.EnableSsl = $UseSsl if ($Credential) { $client.Credentials = [System.Net.NetworkCredential]::new($Credential.UserName, $Credential.Password) } $msg.From = $From foreach ($addr in $To) { $msg.To.Add($addr) } $msg.Subject = $Subject $msg.Body = $Body if ($AttachmentPath) { $attachment = [System.Net.Mail.Attachment]::new($AttachmentPath) $msg.Attachments.Add($attachment) } $client.Send($msg) } finally { if ($attachment) { $attachment.Dispose() } $msg.Dispose() $client.Dispose() } } function Send-DiscoveryEmailSmtp { param( [Parameter(Mandatory)][string]$ZipPath, [Parameter(Mandatory)][string]$SmtpServer, [int]$Port = 587, [bool]$UseSsl = $true, [pscredential]$Credential, [Parameter(Mandatory)][string]$From, [Parameter(Mandatory)][string[]]$To, [string]$Subject = 'Discover-WindowsServer output', [string]$Body = 'Discovery output is attached.', [int]$MaxAttachmentSizeMB = 20 ) Test-DiscoveryAttachmentSize -Path $ZipPath -MaxSizeMB $MaxAttachmentSizeMB Send-ViaSmtpClient -SmtpServer $SmtpServer -Port $Port -UseSsl $UseSsl -Credential $Credential ` -From $From -To $To -Subject $Subject -Body $Body -AttachmentPath $ZipPath } #endregion #region Smtp2Go (HTTP API - api.smtp2go.com/v3/email/send) -------------------- function Send-DiscoveryEmailSmtp2Go { param( [Parameter(Mandatory)][string]$ZipPath, [Parameter(Mandatory)][pscredential]$Credential, # password = API key [Parameter(Mandatory)][string]$From, [Parameter(Mandatory)][string[]]$To, [string]$Subject = 'Discover-WindowsServer output', [string]$Body = 'Discovery output is attached.', [int]$MaxAttachmentSizeMB = 20 ) Test-DiscoveryAttachmentSize -Path $ZipPath -MaxSizeMB $MaxAttachmentSizeMB $apiKey = $Credential.GetNetworkCredential().Password $payload = @{ sender = $From to = @($To) subject = $Subject text_body = $Body attachments = @(@{ filename = [System.IO.Path]::GetFileName($ZipPath) fileblob = (ConvertTo-Base64File -Path $ZipPath) mimetype = 'application/zip' }) } Invoke-RestMethod -Method Post -Uri 'https://api.smtp2go.com/v3/email/send' ` -Headers @{ 'X-Smtp2go-Api-Key' = $apiKey } -ContentType 'application/json' ` -Body ($payload | ConvertTo-Json -Depth 6) } #endregion #region SendGrid (HTTP v3 Mail Send API - api.sendgrid.com/v3/mail/send) ------ function Send-DiscoveryEmailSendGrid { param( [Parameter(Mandatory)][string]$ZipPath, [Parameter(Mandatory)][pscredential]$Credential, # password = API key [Parameter(Mandatory)][string]$From, [Parameter(Mandatory)][string[]]$To, [string]$Subject = 'Discover-WindowsServer output', [string]$Body = 'Discovery output is attached.', [int]$MaxAttachmentSizeMB = 20 ) Test-DiscoveryAttachmentSize -Path $ZipPath -MaxSizeMB $MaxAttachmentSizeMB $apiKey = $Credential.GetNetworkCredential().Password $payload = @{ personalizations = @(@{ to = @($To | ForEach-Object { @{ email = $_ } }) }) from = @{ email = $From } subject = $Subject content = @(@{ type = 'text/plain'; value = $Body }) attachments = @(@{ content = (ConvertTo-Base64File -Path $ZipPath) filename = [System.IO.Path]::GetFileName($ZipPath) type = 'application/zip' disposition = 'attachment' }) } Invoke-RestMethod -Method Post -Uri 'https://api.sendgrid.com/v3/mail/send' ` -Headers @{ Authorization = "Bearer $apiKey" } -ContentType 'application/json' ` -Body ($payload | ConvertTo-Json -Depth 6) } #endregion #region Postal (self-hosted - <host>/api/v1/send/message) --------------------- function Send-DiscoveryEmailPostal { param( [Parameter(Mandatory)][string]$ZipPath, [Parameter(Mandatory)][string]$PostalServerUrl, [Parameter(Mandatory)][pscredential]$Credential, # password = API key [Parameter(Mandatory)][string]$From, [Parameter(Mandatory)][string[]]$To, [string]$Subject = 'Discover-WindowsServer output', [string]$Body = 'Discovery output is attached.', [int]$MaxAttachmentSizeMB = 20 ) Test-DiscoveryAttachmentSize -Path $ZipPath -MaxSizeMB $MaxAttachmentSizeMB Assert-DiscoveryHttpsUrl -Url $PostalServerUrl -Name 'PostalServerUrl' $apiKey = $Credential.GetNetworkCredential().Password $uri = ($PostalServerUrl.TrimEnd('/')) + '/api/v1/send/message' $payload = @{ to = @($To) from = $From subject = $Subject plain_body = $Body attachments = @(@{ name = [System.IO.Path]::GetFileName($ZipPath) content_type = 'application/zip' data = (ConvertTo-Base64File -Path $ZipPath) }) } Invoke-RestMethod -Method Post -Uri $uri ` -Headers @{ 'X-Server-API-Key' = $apiKey } -ContentType 'application/json' ` -Body ($payload | ConvertTo-Json -Depth 6) } #endregion #region Upload (plain HTTPS PUT/POST of the raw file to a user-supplied URL) -- function Invoke-DiscoveryUpload { <# PUT (default) sends the raw file bytes as the body - matches most pre-signed upload URLs (S3 presigned PUT, Azure Blob SAS PUT). POST is available for endpoints that expect that verb instead. Neither wraps the body in multipart/form-data - a generic "upload URL" is far more often a direct-PUT target than a browser-style form post. #> param( [Parameter(Mandatory)][string]$ZipPath, [Parameter(Mandatory)][string]$UploadUrl, [ValidateSet('Put', 'Post')][string]$UploadMethod = 'Put' ) if (-not (Test-Path -LiteralPath $ZipPath)) { throw "File not found: $ZipPath" } Assert-DiscoveryHttpsUrl -Url $UploadUrl -Name 'UploadUrl' Invoke-WebRequest -Method $UploadMethod -Uri $UploadUrl -InFile $ZipPath -ContentType 'application/zip' -UseBasicParsing } #endregion #region Dispatcher -------------------------------------------------------------- function Invoke-DiscoveryDelivery { <# Routes to the right sender based on -Method, resolving credentials as needed. #> [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialName', Justification = 'a credential-store key name, not a secret')] param( [Parameter(Mandatory)][string]$ZipPath, [Parameter(Mandatory)][ValidateSet('Smtp', 'Smtp2Go', 'SendGrid', 'Postal', 'Upload')][string]$Method, [string]$From, [string[]]$To, [string]$Subject, [string]$Body, [string]$SmtpServer, [int]$SmtpPort = 587, [bool]$UseSsl = $true, [string]$PostalServerUrl, [string]$UploadUrl, [string]$UploadMethod = 'Put', [string]$CredentialName, [pscredential]$Credential, [switch]$SaveCredential, [int]$MaxAttachmentSizeMB = 20 ) # [string] parameters left unbound default to '' in PowerShell, not $null - so an explicit # -Credential passed without -CredentialName would otherwise crash Get-OrPromptDeliveryCredential's # Mandatory -Name binding on an empty string. Falling back to the method name keeps # -CredentialName truly optional (matching every -Credential-only test/usage) while still # giving each method its own stable store bucket when nothing more specific is given. $effectiveCredentialName = if ($CredentialName) { $CredentialName } else { $Method } Enable-DiscoveryTls12 switch ($Method) { 'Upload' { return Invoke-DiscoveryUpload -ZipPath $ZipPath -UploadUrl $UploadUrl -UploadMethod $UploadMethod } 'Smtp' { $cred = $null if ($Credential -or $CredentialName) { $cred = Get-OrPromptDeliveryCredential -Name $effectiveCredentialName -Credential $Credential -SaveCredential:$SaveCredential -PromptUserName 'smtp-user' } return Send-DiscoveryEmailSmtp -ZipPath $ZipPath -SmtpServer $SmtpServer -Port $SmtpPort -UseSsl $UseSsl ` -Credential $cred -From $From -To $To -Subject $Subject -Body $Body -MaxAttachmentSizeMB $MaxAttachmentSizeMB } 'Smtp2Go' { $cred = Get-OrPromptDeliveryCredential -Name $effectiveCredentialName -Credential $Credential -SaveCredential:$SaveCredential -PromptUserName 'ApiKey' return Send-DiscoveryEmailSmtp2Go -ZipPath $ZipPath -Credential $cred -From $From -To $To -Subject $Subject -Body $Body -MaxAttachmentSizeMB $MaxAttachmentSizeMB } 'SendGrid' { $cred = Get-OrPromptDeliveryCredential -Name $effectiveCredentialName -Credential $Credential -SaveCredential:$SaveCredential -PromptUserName 'ApiKey' return Send-DiscoveryEmailSendGrid -ZipPath $ZipPath -Credential $cred -From $From -To $To -Subject $Subject -Body $Body -MaxAttachmentSizeMB $MaxAttachmentSizeMB } 'Postal' { $cred = Get-OrPromptDeliveryCredential -Name $effectiveCredentialName -Credential $Credential -SaveCredential:$SaveCredential -PromptUserName 'ApiKey' return Send-DiscoveryEmailPostal -ZipPath $ZipPath -PostalServerUrl $PostalServerUrl -Credential $cred -From $From -To $To -Subject $Subject -Body $Body -MaxAttachmentSizeMB $MaxAttachmentSizeMB } } } #endregion # Only runs when the script is executed directly (.\Send-DiscoveryOutput.ps1 ... or # pwsh -File ...) - not when dot-sourced (as Pester tests do, to reach the functions above # without sending anything). if ($MyInvocation.InvocationName -ne '.') { if (-not $ZipPath -or -not $Method) { throw 'Both -ZipPath and -Method are required when running this script directly.' } Invoke-DiscoveryDelivery -ZipPath $ZipPath -Method $Method -From $From -To $To -Subject $Subject -Body $Body ` -SmtpServer $SmtpServer -SmtpPort $SmtpPort -UseSsl $UseSsl -PostalServerUrl $PostalServerUrl ` -UploadUrl $UploadUrl -UploadMethod $UploadMethod ` -CredentialName $CredentialName -Credential $Credential -SaveCredential:$SaveCredential ` -MaxAttachmentSizeMB $MaxAttachmentSizeMB } |