public/Invoke-AxisCertificateDeployment.ps1

<#
.SYNOPSIS
Deploys or renews CA-issued certificates on many Axis devices in parallel.

.DESCRIPTION
Runs Request-AxisCertificate against a list of devices (given, read from a file, or discovered on the local
network by Axis MAC prefix) using a runspace pool, and returns one result row per device. Use -Mode to limit the
run to first-time deployment (Apply), renewals (Renew) or both (Auto, default). Devices whose certificate is
still valid are reported as Current and left alone, so the command is safe to schedule for lifecycle management.
Per-device log files are written when logging is enabled (Set-AxisPSConfig -EnableLogging).

.PARAMETER IP
IP addresses or hostnames of the devices.

.PARAMETER InputFile
A text file with one device per line, or a CSV with an IP (or Device) column.

.PARAMETER InterfaceIP
Discover devices on the subnet of this local interface instead of supplying a list.

.PARAMETER CertificateAuthority
Name of a configured authority. Defaults to the template's DefaultAuthority.

.PARAMETER Mode
Auto (default): issue where missing, renew where expiring. Apply: only devices without a CA-issued certificate.
Renew: only devices that already have one.

.PARAMETER RenewWithinDays
Renewal window override.

.PARAMETER Force
Re-issue on every device, including devices with valid certificates.

.PARAMETER NoRevoke
Do not revoke superseded certificates on the CA.

.PARAMETER ThrottleLimit
Maximum parallel devices. Default 10, to keep the CA responsive.

.PARAMETER ReportPath
Write the results to this CSV file as well.

.EXAMPLE
Invoke-AxisCertificateDeployment -IP 192.168.1.10,192.168.1.11 -CertificateAuthority CorpCA

.EXAMPLE
Invoke-AxisCertificateDeployment -InputFile .\cameras.csv -Mode Renew -ReportPath .\renewals.csv
#>

function Invoke-AxisCertificateDeployment {
    [cmdletbinding(DefaultParameterSetName='IP')]
    Param(
        [Parameter(Mandatory,ParameterSetName='IP')]
        [String[]]$IP,

        [Parameter(Mandatory,ParameterSetName='File')]
        [String]$InputFile,

        [Parameter(Mandatory,ParameterSetName='Discover')]
        [String]$InterfaceIP,

        [Parameter()]
        [String]$CertificateAuthority,

        [Parameter()]
        [ValidateSet("Auto","Apply","Renew")]
        [String]$Mode="Auto",

        [Parameter()]
        [Int]$RenewWithinDays,

        [Parameter()]
        [Switch]$Force,

        [Parameter()]
        [Switch]$NoRevoke,

        [Parameter()]
        [ValidateRange(1,50)]
        [Int]$ThrottleLimit=10,

        [Parameter()]
        [String]$ReportPath,

        [Parameter(DontShow)]
        [Switch]$SingleThreaded
    )

    Check-Credential
    #Fail early if the authority is not configured
    $Authority = Resolve-AxisCertificateAuthority -Name $CertificateAuthority

    $ModuleString = "AxisPowershell"
    if($env:TestModulePath) {
        $ModuleString = $env:TestModulePath
    }

    ######################
    ## Build target list ##
    ######################
    $TargetDevices = @()
    switch($PSCmdlet.ParameterSetName) {
        "IP" {
            $TargetDevices = $IP
        }
        "File" {
            $Content = Get-Content -Path $InputFile | Where-Object { $_.Trim() -ne "" }
            if($Content[0] -match ',') {
                $Csv = $Content | ConvertFrom-Csv
                $Column = @($Csv[0].PSObject.Properties.Name) | Where-Object { $_ -in "IP","Device","IPAddress","Host" } | Select-Object -First 1
                if(!$Column) { Throw "CSV file needs an IP, Device, IPAddress or Host column." }
                $TargetDevices = $Csv.$Column
            }
            else {
                $TargetDevices = $Content | ForEach-Object { $_.Trim() }
            }
        }
        "Discover" {
            #Same OUI filter as New-AxisProvisioningJob
            $AxisOUIs = @(
                "e8-27-25"
                "00-40-8c"
                "b8-a4-4f"
                "ac-cc-8e"
            )
            $ipInfo = Get-NetIPAddress $InterfaceIP
            $IPRange = Out-SubnetRange -Subnet $ipInfo.IPAddress -Prefix $ipInfo.PrefixLength
            $TargetDevices = (Find-LANHosts -IP $IPRange | Where-Object { $_.MACAddress -match "($($AxisOUIs -join '|'))" }).IP
        }
    }
    $TargetDevices = @($TargetDevices | Where-Object { $_ } | Select-Object -Unique)

    if($TargetDevices.Count -eq 0) {
        Throw "No Axis Devices Found"
    }
    Write-Verbose "Deploying certificates from $($Authority.Name) to $($TargetDevices.Count) device(s), mode $Mode"

    $RequestParam = @{
        CertificateAuthority = $Authority.Name
        Force = [bool]$Force
        NoRevoke = [bool]$NoRevoke
    }
    if($RenewWithinDays) { $RequestParam.Add('RenewWithinDays',$RenewWithinDays) }

    #Per-device work: Mode filter, then Request-AxisCertificate; every outcome becomes a row
    $Worker = {
        param($Device, $Mode, $RequestParam)

        $Row = [pscustomobject]@{
            Device = $Device
            Action = "Failed"
            Alias = $null
            Subject = $null
            Serial = $null
            NotAfter = $null
            Thumbprint = $null
            HttpsVerified = $false
            RevokedSerial = $null
            Authority = $RequestParam.CertificateAuthority
            Message = ""
        }

        Try {
            if($Mode -ne "Auto") {
                $Alias = $Config.Certificate.Alias
                $HasCert = [bool](Get-AxisCertificate -Device $Device | Where-Object { ($_.Alias -eq $Alias -or $_.Alias -like "$Alias-*") -and $_.Issuer -ne $_.Subject })
                if($Mode -eq "Apply" -and $HasCert) {
                    $Row.Action = "Skipped"
                    $Row.Message = "Already has a CA-issued certificate (Mode Apply)"
                    return $Row
                }
                if($Mode -eq "Renew" -and !$HasCert) {
                    $Row.Action = "Skipped"
                    $Row.Message = "No CA-issued certificate to renew (Mode Renew)"
                    return $Row
                }
            }

            Write-Log -ID $Device -Message "Certificate deployment started"
            $Row = Request-AxisCertificate -Device $Device @RequestParam
            Write-Log -ID $Device -Message "Certificate deployment: $($Row.Action) - $($Row.Message)"
        }
        Catch {
            $Row.Message = $_.Exception.Message
            Write-Log -ID $Device -Message "Certificate deployment failed: $($Row.Message)"
        }

        return $Row
    }

    $Results = @()

    ##Single Thread
    if($SingleThreaded) {
        ForEach($Device in $TargetDevices) {
            $Results += & $Worker $Device $Mode $RequestParam
        }
    }
    else {
        ##MultiThreaded
        $runspacePool = [runspacefactory]::CreateRunspacePool(1, $ThrottleLimit)
        $runspacePool.Open()

        $ScriptBlock = {
            param($RuntimeData)
            Import-Module $RuntimeData.ModulePath
            Set-AxisCredential -Credential $RuntimeData.Credential
            #Run the worker inside the module so it sees $Config and private functions
            $Module = Get-Module AxisPowershell
            & $Module ([scriptblock]::Create($RuntimeData.Worker)) $RuntimeData.Device $RuntimeData.Mode $RuntimeData.RequestParam
        }

        #@() keeps a single job an array on Windows PowerShell 5.1 (otherwise .Count is null -> divide by zero)
        $jobs = @(ForEach($Device in $TargetDevices) {
            $RuntimeData = @{
                ModulePath = $ModuleString
                Credential = $Config.Credential
                Worker = $Worker.ToString()
                Device = $Device
                Mode = $Mode
                RequestParam = $RequestParam
            }
            $PowerShell = [powershell]::Create().AddScript($ScriptBlock).AddArgument($RuntimeData)
            $PowerShell.RunspacePool = $runspacePool
            [pscustomobject]@{
                Device = $Device
                PowerShell = $PowerShell
                Handle = $PowerShell.BeginInvoke()
            }
        })

        $Total = $jobs.Count
        While($jobs | Where-Object { !$_.Handle.IsCompleted }) {
            $Done = ($jobs | Where-Object { $_.Handle.IsCompleted }).Count
            Write-Progress -Activity "Deploying certificates" -Status "$Done of $Total devices complete" -PercentComplete (100 * $Done / $Total)
            Start-Sleep -Milliseconds 500
        }
        Write-Progress -Activity "Deploying certificates" -Completed

        ForEach($job in $jobs) {
            Try {
                $Output = $job.PowerShell.EndInvoke($job.Handle)
                $Row = $Output | Where-Object { $_.PSObject.Properties.Name -contains "Action" } | Select-Object -Last 1
                if(!$Row) {
                    $Row = [pscustomobject]@{ Device = $job.Device; Action = "Failed"; Alias = $null; Subject = $null; Serial = $null; NotAfter = $null; Thumbprint = $null; HttpsVerified = $false; RevokedSerial = $null; Authority = $Authority.Name; Message = "No result returned" }
                }
                if($job.PowerShell.Streams.Error.Count -gt 0 -and $Row.Action -eq "Failed" -and !$Row.Message) {
                    $Row.Message = $job.PowerShell.Streams.Error[0].ToString()
                }
                $Results += $Row
            }
            Catch {
                $Results += [pscustomobject]@{ Device = $job.Device; Action = "Failed"; Alias = $null; Subject = $null; Serial = $null; NotAfter = $null; Thumbprint = $null; HttpsVerified = $false; RevokedSerial = $null; Authority = $Authority.Name; Message = $_.Exception.Message }
            }
            Finally {
                $job.PowerShell.Dispose()
            }
        }

        $runspacePool.Close()
        $runspacePool.Dispose()
    }

    if($ReportPath) {
        $Results | Select-Object Device, Action, Alias, Subject, Serial, NotAfter, Thumbprint, HttpsVerified, RevokedSerial, Authority, Message |
            Export-Csv -Path $ReportPath -NoTypeInformation
        Write-Verbose "Report written to $ReportPath"
    }

    $Summary = $Results | Group-Object Action | ForEach-Object { "$($_.Name): $($_.Count)" }
    Write-Verbose "Deployment complete - $($Summary -join ', ')"

    return $Results
}