Public/Invoke-CertWatchtowerScan.ps1

function Invoke-CertWatchtowerScan {
    <#
    .SYNOPSIS
        Scan orchestrator: collects snapshots from one or many servers -
        standalone via -ComputerName (default: local machine) or multi-tenant
        via JSON config files.
    .DESCRIPTION
        Standalone mode (default) needs no configuration at all:
 
            Invoke-CertWatchtowerScan -IncludeCrl -IncludeCA |
                New-CwReport -OutFile .\cert-watchtower.html
 
        scans the local machine; -ComputerName dc01,dc02 scans remote servers
        over the existing Kerberos/WinRM context (Invoke-Command). The needed
        cert-watchtower functions are injected into the remote session as
        text; nothing is installed on the target.
 
        Multi-tenant mode reads one JSON file per tenant (glob supported):
 
            {
              "tenant": "Contoso AG",
              "servers": ["dc01.contoso.local", "srv-web01.contoso.local"],
              "warnDays": 30,
              "critDays": 7,
              "endpoints": ["ldaps://dc01.contoso.local:636"]
            }
 
        Configs contain host lists and thresholds - never secrets. With
        -SnapshotPath every snapshot is additionally persisted as JSON
        (the canonical artifact for later reports, diffs and baselines);
        without it the snapshots are only emitted to the pipeline.
    .EXAMPLE
        # simplest possible: scan this machine, render a report
        Invoke-CertWatchtowerScan | New-CwReport -OutFile .\cert-watchtower.html
    .EXAMPLE
        Invoke-CertWatchtowerScan -ComputerName dc01,dc02 -IncludeCrl -IncludeCA -SnapshotPath .\snapshots
    .EXAMPLE
        Invoke-CertWatchtowerScan -TenantConfig .\tenants\*.json -SnapshotPath .\snapshots -IncludeCrl |
            New-CwReport -OutFile .\out\fleet.html
    #>

    [CmdletBinding(DefaultParameterSetName = 'Servers')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Config')]
        [string]$TenantConfig,

        [Parameter(ParameterSetName = 'Servers', Position = 0)]
        [Alias('Server', 'Servers')]
        [string[]]$ComputerName = @('localhost'),

        [Parameter(ParameterSetName = 'Servers')]
        [string]$Tenant,

        [Parameter(ParameterSetName = 'Servers')]
        [int]$WarnDays = 30,

        [Parameter(ParameterSetName = 'Servers')]
        [int]$CritDays = 7,

        [Parameter(ParameterSetName = 'Servers')]
        [string[]]$ProbeEndpoints,

        [string]$SnapshotPath,

        [switch]$IncludeCrl,
        [switch]$IncludeCA,

        [pscredential]$Credential
    )

    # --- build the flat work list (tenant, server, thresholds, endpoints) ----
    $jobs = [System.Collections.Generic.List[object]]::new()

    if ($PSCmdlet.ParameterSetName -eq 'Config') {
        $configFiles = @(Get-ChildItem -Path $TenantConfig -File -ErrorAction Stop)
        if ($configFiles.Count -eq 0) {
            throw "No tenant config files match '$TenantConfig'."
        }
        foreach ($file in $configFiles) {
            $config = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json
            $cfgTenant = if ($config.PSObject.Properties['tenant']) { $config.tenant } else { $file.BaseName }
            $cfgWarn = if ($config.PSObject.Properties['warnDays']) { [int]$config.warnDays } else { 30 }
            $cfgCrit = if ($config.PSObject.Properties['critDays']) { [int]$config.critDays } else { 7 }
            $cfgEndpoints = if ($config.PSObject.Properties['endpoints']) { @($config.endpoints) } else { @() }
            foreach ($server in @($config.servers)) {
                $jobs.Add([pscustomobject]@{
                        Tenant = $cfgTenant; Server = $server
                        WarnDays = $cfgWarn; CritDays = $cfgCrit; Endpoints = $cfgEndpoints
                    })
            }
        }
    }
    else {
        foreach ($server in $ComputerName) {
            $jobs.Add([pscustomobject]@{
                    Tenant = $Tenant; Server = $server
                    WarnDays = $WarnDays; CritDays = $CritDays; Endpoints = @($ProbeEndpoints | Where-Object { $_ })
                })
        }
    }

    if ($SnapshotPath -and -not (Test-Path -Path $SnapshotPath)) {
        $null = New-Item -Path $SnapshotPath -ItemType Directory -Force
    }

    # function definitions shipped into remote sessions (no install on target)
    $remoteDefText = $null
    $localNames = @('localhost', '127.0.0.1', '.', $env:COMPUTERNAME,
        "$env:COMPUTERNAME.$env:USERDNSDOMAIN".TrimEnd('.'))

    foreach ($job in $jobs) {
        $label = if ($job.Tenant) { "[$($job.Tenant)] " } else { '' }
        Write-Verbose "$($label)collecting snapshot from $($job.Server)"
        $isLocal = $job.Server -in $localNames

        try {
            $snapshot = if ($isLocal) {
                Get-CwSnapshot -IncludeCrl:$IncludeCrl -IncludeCA:$IncludeCA `
                    -Tenant $job.Tenant -WarnDays $job.WarnDays -CritDays $job.CritDays
            }
            else {
                if (-not $remoteDefText) {
                    $remoteDefText = (@(
                            'ConvertTo-CwSeverity', 'Get-CwCertRole', 'ConvertFrom-CwCrl',
                            'Get-CwCertInventory', 'Test-CwCrlHealth', 'Get-CwCaHealth',
                            'Test-CwEndpoint', 'Get-CwSnapshot'
                        ) | ForEach-Object {
                            $fn = Get-Command -Name $_ -CommandType Function -ErrorAction Stop
                            "function $($fn.Name) { $($fn.Definition) }"
                        }) -join "`n"
                }
                $icmParams = @{
                    ComputerName = $job.Server
                    ScriptBlock  = {
                        param($defs, $params)
                        . ([scriptblock]::Create($defs))
                        Get-CwSnapshot @params
                    }
                    ArgumentList = @($remoteDefText, @{
                            IncludeCrl = [bool]$IncludeCrl
                            IncludeCA  = [bool]$IncludeCA
                            Tenant     = $job.Tenant
                            WarnDays   = $job.WarnDays
                            CritDays   = $job.CritDays
                        })
                    ErrorAction  = 'Stop'
                }
                if ($Credential) { $icmParams.Credential = $Credential }
                Invoke-Command @icmParams |
                    Select-Object -Property * -ExcludeProperty PSComputerName, RunspaceId, PSShowComputerName
            }

            # endpoint probes always run from the scanning host (outside view)
            if (@($job.Endpoints).Count -gt 0) {
                $snapshot.Endpoints = @(Test-CwEndpoint -Uri $job.Endpoints -WarnDays $job.WarnDays -CritDays $job.CritDays)
            }

            if ($SnapshotPath) {
                $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss')
                $safeTenant = if ($job.Tenant) { ($job.Tenant -replace '[^\w\-]', '_') + '_' } else { '' }
                $safeHost = $job.Server -replace '[^\w\-.]', '_'
                $outFile = Join-Path $SnapshotPath "$safeTenant$safeHost`_$stamp.json"
                $snapshot | ConvertTo-Json -Depth 8 | Set-Content -Path $outFile -Encoding utf8
                Write-Verbose "snapshot written: $outFile"
            }

            $snapshot
        }
        catch {
            Write-Warning "$label$($job.Server) failed: $_"
        }
    }
}