public/Get-AxisCertificateSubject.ps1

<#
.SYNOPSIS
Builds the certificate subject and subject alternative names for an Axis device from templates.

.DESCRIPTION
The Get-AxisCertificateSubject function resolves the template tokens {Hostname} {DNSSuffix} {FQDN} {IP} {Serial}
{MAC} {Model} using Get-AxisDeviceInfo, Get-AxisNetworkInfo and the module configuration ($Config.DNSSuffix).
Templates default to $Config.Certificate.SubjectTemplate / SanTemplate / IncludeIP.
The SAN list always contains DNS:<CN of the subject>; IP:<address> is only added with -IncludeIP.

.PARAMETER Device
Specifies the IP address or hostname of the Axis device.

.PARAMETER SubjectTemplate
Subject DN template (RFC 4514), e.g. "CN={FQDN},O=Example". Default from configuration ("CN={FQDN}").

.PARAMETER SanTemplate
One or more SAN templates, each prefixed with DNS: or IP:, e.g. "DNS:{FQDN}","DNS:{Hostname}". Default from configuration.

.PARAMETER IncludeIP
Add IP:<device IP> to the SAN list. Default from configuration.

.EXAMPLE
Get-AxisCertificateSubject -Device "192.168.1.100"

Name Value
---- -----
Subject CN=axis-b8a44fb197bb.example.com
SubjectAltNames {DNS:axis-b8a44fb197bb.example.com}

.EXAMPLE
Get-AxisCertificateSubject -Device "192.168.1.100" -SubjectTemplate "CN={Serial},O=Example" -IncludeIP
#>

function Get-AxisCertificateSubject {
    [cmdletbinding()]
    Param(
        [Parameter(Mandatory)]
        [String]$Device,

        [Parameter()]
        [String]$SubjectTemplate,

        [Parameter()]
        [String[]]$SanTemplate,

        [Parameter()]
        [Switch]$IncludeIP
    )

    #Defaults from configuration
    $CertConfig = $Config.Certificate
    if(!$SubjectTemplate) {
        $SubjectTemplate = $CertConfig.SubjectTemplate
        if(!$SubjectTemplate) { $SubjectTemplate = "CN={FQDN}" }
    }
    if(!$SanTemplate) {
        $SanTemplate = @($CertConfig.SanTemplate)
        if(!$SanTemplate -or $SanTemplate.Count -eq 0) { $SanTemplate = @("DNS:{FQDN}") }
    }
    if(!$PSBoundParameters.ContainsKey('IncludeIP')) {
        $IncludeIP = [bool]$CertConfig.IncludeIP
    }

    Try {
        $DeviceInfo = Get-AxisDeviceInfo -Device $Device
        $NetworkInfo = Get-AxisNetworkInfo -Device $Device
    }
    Catch {
        Throw "Unable to read device information from $($Device): $_"
    }

    $Hostname = [String]$NetworkInfo.HostName
    $IP = [String]$NetworkInfo.IPAddress
    #Lower-case so that CN=axis-{Serial}.<suffix> matches the device's own default hostname (axis-<serial>)
    $Serial = ([String]$DeviceInfo.SerialNumber).ToLower()
    $Model = [String]$DeviceInfo.ProdNbr

    #DNS suffix: configuration wins, otherwise what the device registers via DNS update
    $DNSSuffix = ""
    if($Config.DNSSuffix) {
        $DNSSuffix = ([String]$Config.DNSSuffix).TrimStart('.')
    }
    elseif($NetworkInfo.DNSHostName -and ([String]$NetworkInfo.DNSHostName).Contains('.')) {
        $DNSSuffix = ([String]$NetworkInfo.DNSHostName).Split('.',2)[1]
    }

    if($Hostname -and $DNSSuffix) {
        $FQDN = "$Hostname.$DNSSuffix"
    }
    elseif($Hostname) {
        $FQDN = $Hostname
    }
    else {
        $FQDN = $Device
    }

    #Axis serial numbers are the MAC address without separators
    $MAC = $Serial
    if($Serial -match '^[0-9A-Fa-f]{12}$') {
        $MAC = (($Serial.ToUpper() -split '(..)' | Where-Object { $_ }) -join ':')
    }

    $Tokens = @{
        Hostname = $Hostname
        DNSSuffix = $DNSSuffix
        FQDN = $FQDN
        IP = $IP
        Serial = $Serial
        MAC = $MAC
        Model = $Model
    }

    $Resolve = {
        Param($Template)
        $Value = [String]$Template
        ForEach($Key in $Tokens.Keys) {
            $Value = [regex]::Replace($Value, "\{$Key\}", [String]$Tokens[$Key], [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        }
        return $Value
    }

    $Subject = & $Resolve $SubjectTemplate

    $CN = $null
    if($Subject -match '(?i)(?:^|,)\s*CN=([^,]+)') {
        $CN = $Matches[1].Trim()
    }

    $SubjectAltNames = New-Object System.Collections.ArrayList
    if($CN) {
        if($CN -match '^\d{1,3}(\.\d{1,3}){3}$') {
            #A bare IP in the CN only makes sense as an IP SAN
            if($IncludeIP) { $null = $SubjectAltNames.Add("IP:$CN") }
        }
        else {
            $null = $SubjectAltNames.Add("DNS:$CN")
        }
    }
    ForEach($Template in $SanTemplate) {
        $Entry = & $Resolve $Template
        if($Entry -notmatch '^(DNS|IP):.+$') {
            Write-Verbose "Skipping SAN template '$Template': resolved to '$Entry'"
            continue
        }
        if($Entry -match '^DNS:(\d{1,3}(\.\d{1,3}){3})$') {
            #A DNS name that resolved to a bare IP (device without hostname) is only valid as an IP SAN
            $Entry = "IP:$($Matches[1])"
        }
        if($Entry -like "IP:*" -and !$IncludeIP) {
            continue
        }
        if($SubjectAltNames -notcontains $Entry) { $null = $SubjectAltNames.Add($Entry) }
    }
    if($IncludeIP -and $IP -and $SubjectAltNames -notcontains "IP:$IP") {
        $null = $SubjectAltNames.Add("IP:$IP")
    }

    return @{
        Subject = $Subject
        SubjectAltNames = @($SubjectAltNames)
    }
}