public/Set-AxisCertificateEnrollment.ps1
|
<# .SYNOPSIS Hands certificate enrollment and renewal over to the Axis device itself (EST profile, AXIS OS 12.9/12.10+). .DESCRIPTION The Set-AxisCertificateEnrollment function creates (or updates) an EST profile on an Axis device through the VAPIX Enrollment over secure transport API (POST/PATCH /config/rest/cert-est/v1/profiles). From then on the device acts as the EST client (RFC 7030) and the module is no longer involved: - the device sends a CSR to <server>/simpleenroll as soon as server, certificate and caCertificates are set, - it re-enrolls (/simplereenroll) at 50 % of the certificate validity and retries daily on failure, - the enrolled certificate is assigned to the selected services (WEBSERVER by default), - failures are published as the stateless event tnsaxis:CertificateManagement/EST/Error (ErrorCode 0-7, readable via ws(s)://<device>/vapix/ws-data-stream?sources=events). The profile status (UNCONNECTED / CONNECTED / ENROLLED) can be read with Get-AxisCertificateEnrollment. Bootstrap trust: CA certificates returned by the EST /cacerts operation are added to the device trust store automatically, without out-of-band verification. Whichever server the device reaches first becomes trusted. This function therefore pre-loads the EST server root CA (-CACertificate) into the device trust store (POST /config/rest/cert/v1/ca_certificates, skipped when the same thumbprint is already present) and references its alias in the profile's caCertificates list, so the device authenticates the server before trusting anything. A profile without a CA certificate alias is refused for that reason. The device authenticates itself to the EST server with mutual TLS using an existing device certificate (-ClientCertificateAlias). By default the Axis device ID (IEEE 802.1AR) certificate is used when it can be found with Get-AxisCertificate; Dogtag/EJBCA/ClearPass EST servers trust the Axis device ID chain for this. The authority must be of Type Dogtag or EST (New-AxisCertificateAuthority). The EST URL sent to the device is built as "host:port/.well-known/est[/label]" from Authority.Server (Dogtag: EstServer when set) and Authority.Label (fallback Authority.Template). Credentials stored for the authority are NOT used: the device only supports certificate (mTLS) authentication, so the EST server must accept the client certificate. A device holds at most two EST profiles (AXIS OS 12.11+; one before). Sources: https://developer.axis.com/vapix/device-configuration/enrollment-over-secure-transport-api/ https://help.axis.com/en-us/axis-os-knowledge-base ; https://newsroom.axis.com/en-us/news/support-est https://www.rfc-editor.org/rfc/rfc7030 .PARAMETER Device Specifies the IP address or hostname of the Axis device. .PARAMETER CertificateAuthority Name of a configured certificate authority of Type Dogtag or EST. Default $Config.Certificate.DefaultAuthority. .PARAMETER ProfileId EST profile identifier on the device (1-128 characters). Default "AxisPowershell". An existing profile with this id is patched. .PARAMETER ClientCertificateAlias Alias of an existing device certificate used to authenticate to the EST server (mutual TLS). Default: the Axis device ID certificate, if one is found on the device; otherwise the parameter is required. .PARAMETER CACertificate The EST server's root (or issuing) CA certificate as PEM text or a PEM file path. Installed on the device when not already present. Alternatively the alias of a CA certificate that is already installed on the device. When omitted, a CA certificate already on the device matching Authority.CAThumbprint is used. .PARAMETER Services Services the enrolled certificate is assigned to: WEBSERVER, NETAUTH, MQTT, RTSPS. Default WEBSERVER. .PARAMETER SubjectTemplate Subject DN template passed to Get-AxisCertificateSubject (only the CN is sent to the device). Default from configuration. .PARAMETER SanTemplate SAN templates passed to Get-AxisCertificateSubject. Default from configuration. .PARAMETER HttpsOnly Force HTTPS for all user roles (System.BoaGroupPolicy.*=https). Default $Config.Certificate.HttpsOnly. Note: the setting is applied immediately, before the device has enrolled; the web server keeps serving its current certificate until then. .EXAMPLE Set-AxisCertificateEnrollment -Device "192.168.1.100" -CertificateAuthority Dogtag -CACertificate C:\pki\dogtag-root.pem Device : 192.168.1.100 ProfileId : AxisPowershell Server : pki.example.com:8443/.well-known/est/cameras Status : UNCONNECTED .EXAMPLE Set-AxisCertificateEnrollment -Device "192.168.1.100" -ClientCertificateAlias "AxisPowershell" -CACertificate "EST-Root" -Services WEBSERVER,RTSPS -HttpsOnly:$false Uses a previously issued module certificate for mTLS and the CA certificate already installed under alias "EST-Root". #> function Set-AxisCertificateEnrollment { [cmdletbinding()] Param( [Parameter(Mandatory)] [String]$Device, [Parameter()] [String]$CertificateAuthority, [Parameter()] [ValidateLength(1,128)] [String]$ProfileId="AxisPowershell", [Parameter()] [String]$ClientCertificateAlias, [Parameter()] [String]$CACertificate, [Parameter()] [ValidateSet("WEBSERVER","NETAUTH","MQTT","RTSPS")] [String[]]$Services=@("WEBSERVER"), [Parameter()] [String]$SubjectTemplate, [Parameter()] [String[]]$SanTemplate, [Parameter()] [Switch]$HttpsOnly ) if(!$PSBoundParameters.ContainsKey('HttpsOnly')) { $HttpsOnly = [bool]$Config.Certificate.HttpsOnly } #Authority: only EST-capable types can be handed off to the device $Authority = Resolve-AxisCertificateAuthority -Name $CertificateAuthority if($Authority.Type -notin @("Dogtag","EST")) { Throw "Certificate authority '$($Authority.Name)' is of type '$($Authority.Type)'; native device enrollment requires an EST capable authority (Type Dogtag or EST)" } $Server = $Authority.Server if($Authority.Type -eq "Dogtag" -and $Authority.EstServer) { $Server = $Authority.EstServer } if(!$Server) { Throw "Certificate authority '$($Authority.Name)' has no Server (expected 'https://host:port')" } #Device expects "host[:port][/.well-known/est[/label]]" without scheme (API doc: "192.168.0.1:443/.well-known/est") $HostPort = ([String]$Server).Trim() $HostPort = $HostPort -replace '^[A-Za-z][A-Za-z0-9+.-]*://','' $HostPort = $HostPort.Split('/')[0] if(!$HostPort) { Throw "Unable to determine host and port from authority server '$Server'" } $EstServer = "$HostPort/.well-known/est" $Label = $Authority.Label if(!$Label) { $Label = $Authority.Template } if($Label) { $EstServer = "$EstServer/$(([String]$Label).Trim('/'))" } #API availability (AXIS OS 12.9/12.10+); throws "Command not supported on this device" otherwise $BasePath = Get-AxisConfigApiPath -Device $Device -API "cert-est" #Existing device certificates: default mTLS client certificate = Axis device ID (IEEE 802.1AR) $DeviceCerts = @() Try { $DeviceCerts = @(Get-AxisCertificate -Device $Device) } Catch { Write-Verbose "Could not list device certificates on $($Device): $_" } if(!$ClientCertificateAlias) { #Alias/subject/issuer naming of the factory certificate is not documented; match on "Axis device ID", prefer ECC $DeviceIds = @($DeviceCerts | Where-Object { $_.Alias -like "*Axis*device*ID*" -or $_.Subject -like "*Axis*Device*ID*" -or $_.Issuer -like "*Axis*Device*ID*" }) $Candidate = $DeviceIds | Where-Object { $_.KeyType -like "NIST-*" } | Select-Object -First 1 if(!$Candidate) { $Candidate = $DeviceIds | Select-Object -First 1 } if(!$Candidate) { Throw "No Axis device ID certificate found on $Device; specify -ClientCertificateAlias with the alias of an existing device certificate to authenticate to the EST server" } $ClientCertificateAlias = $Candidate.Alias Write-Verbose "Using '$ClientCertificateAlias' as EST client certificate" } elseif($DeviceCerts.Count -gt 0 -and ($DeviceCerts | ForEach-Object { $_.Alias }) -notcontains $ClientCertificateAlias) { Throw "Certificate '$ClientCertificateAlias' does not exist on $Device" } #EST server CA: pre-load on the device so the first contact is authenticated (bootstrap trust problem) $ExistingCAs = @() Try { $ExistingCAs = @(Get-AxisCertificate -Device $Device -CA) } Catch { Write-Verbose "Could not list CA certificates on $($Device): $_" } $CaAlias = $null if($CACertificate) { $CaPem = $null if($CACertificate -like "*-----BEGIN*") { $CaPem = $CACertificate } elseif(Test-Path -LiteralPath $CACertificate) { $CaPem = [String](Get-Content -LiteralPath $CACertificate -Raw) } elseif(($ExistingCAs | ForEach-Object { $_.Alias }) -contains $CACertificate) { $CaAlias = $CACertificate } else { Throw "'$CACertificate' is neither PEM text, an existing file nor the alias of a CA certificate installed on $Device" } if($CaPem) { $Blocks = @(ConvertFrom-PemString -Pem $CaPem | Where-Object { $_.Label -eq "CERTIFICATE" }) if($Blocks.Count -eq 0) { Throw "No CERTIFICATE block found in -CACertificate" } #Devices reject bundles; the first block is the trust anchor used for the EST server $CaPem = $Blocks[0].Pem $CaCert = ConvertFrom-PemString -Pem $CaPem -AsCertificate | Select-Object -First 1 $Present = $ExistingCAs | Where-Object { $_.Thumbprint -eq $CaCert.Thumbprint } | Select-Object -First 1 if($Present) { $CaAlias = $Present.Alias Write-Verbose "CA certificate $($CaCert.Thumbprint) already installed as '$CaAlias'; skipping" } else { #Alias from the CN, safe characters only, unique on the device (same scheme as Install-AxisCertificate) $CN = $CaCert.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) if(!$CN) { $CN = $CaCert.Thumbprint } $CaAlias = ($CN -replace '[^A-Za-z0-9._-]','-').Trim('-') if($CaAlias.Length -gt 120) { $CaAlias = $CaAlias.Substring(0,120) } $ExistingAliases = @($ExistingCAs | ForEach-Object { $_.Alias }) $Base = $CaAlias $n = 2 while($ExistingAliases -contains $CaAlias) { $CaAlias = "$Base-$n" $n++ } Write-Verbose "Installing EST server CA certificate '$CaAlias' ($($CaCert.Thumbprint)) on $Device" Try { #Install-AxisCertificate cannot install a CA certificate on its own, so call the certificate API directly $null = Invoke-AxisCertApi -Device $Device -Path "/ca_certificates" -Method POST -Data @{ alias = $CaAlias; certificate = $CaPem } } Catch { Throw "Unable to install CA certificate '$CaAlias' on $($Device): $_" } } } } elseif($Authority.CAThumbprint) { $Pin = ($Authority.CAThumbprint -replace '[^0-9A-Fa-f]','').ToUpper() $Present = $ExistingCAs | Where-Object { $_.Thumbprint -eq $Pin } | Select-Object -First 1 if($Present) { $CaAlias = $Present.Alias Write-Verbose "Using installed CA certificate '$CaAlias' (matches authority CAThumbprint)" } } if(!$CaAlias) { Throw "No EST server CA certificate available on $Device. Supply -CACertificate (PEM or file) so the device can authenticate the EST server; without it the device would blindly trust the first /cacerts response" } #Subject / SAN for the CSR the device will generate Try { $SubjectParam = @{ Device = $Device } if($SubjectTemplate) { $SubjectParam.Add('SubjectTemplate',$SubjectTemplate) } if($SanTemplate) { $SubjectParam.Add('SanTemplate',$SanTemplate) } $SubjectInfo = Get-AxisCertificateSubject @SubjectParam } Catch { Throw "Unable to build the certificate subject for $($Device): $_" } $CN = $null if([String]$SubjectInfo.Subject -match '(?i)(?:^|,)\s*CN=([^,]+)') { $CN = $Matches[1].Trim() } if(!$CN) { Throw "Subject '$($SubjectInfo.Subject)' contains no CN; the EST profile only supports a Common Name" } if($CN.Length -gt 64) { Throw "Common Name '$CN' exceeds the 64 character limit of the EST profile" } $SANs = @($SubjectInfo.SubjectAltNames | Where-Object { $_ -match '^(DNS|IP):.+$' }) $ProfileData = @{ id = $ProfileId server = $EstServer caCertificates = @($CaAlias) certificate = $ClientCertificateAlias subject = @{ cn = $CN } subjectAlternativeNames = $SANs services = @($Services) } #Create or update the profile (collection keyed on id; devices hold at most 2 profiles) $Existing = @() Try { $Response = Invoke-AxisWebApi -Device $Device -Path "$BasePath/profiles" if($Response.status -and $Response.status -ne "success") { Throw "$($Response.error.message)" } $Existing = @($Response.data | Where-Object { $_ }) } Catch { Throw "Unable to list EST profiles on $($Device): $_" } $EncodedId = [uri]::EscapeDataString($ProfileId) if(($Existing | ForEach-Object { $_.id }) -contains $ProfileId) { Write-Verbose "Updating EST profile '$ProfileId' on $Device" $Body = $ProfileData.Clone() $Body.Remove('id') $Param = @{ Device = $Device Path = "$BasePath/profiles/$EncodedId" Method = "PATCH" Body = @{ data = $Body } } $Action = "Updated" } else { if($Existing.Count -ge 2) { Throw "$Device already holds $($Existing.Count) EST profiles ($(($Existing | ForEach-Object { $_.id }) -join ', ')); remove one with Remove-AxisCertificateEnrollment first" } Write-Verbose "Creating EST profile '$ProfileId' on $Device (server $EstServer)" $Param = @{ Device = $Device Path = "$BasePath/profiles" Method = "POST" Body = @{ data = $ProfileData } } $Action = "Created" } Try { $Response = Invoke-AxisWebApi @Param } Catch { Throw "Unable to set EST profile '$ProfileId' on $($Device): $_" } if($Response.status -and $Response.status -ne "success") { $Message = $Response.error.message if(!$Message) { $Message = "Unknown error" } Throw "Unable to set EST profile '$ProfileId' on $($Device): $Message" } #Enforce HTTPS (same parameters as Install-AxisCertificate; https://help.axis.com/en-us/axis-os-hardening-guide) if($HttpsOnly) { $ParamSet = @{ "System.BoaGroupPolicy.admin" = 'https' "System.BoaGroupPolicy.operator" = 'https' "System.BoaGroupPolicy.viewer" = 'https' } Try { Update-AxisParameter -Device $Device -ParameterSet $ParamSet } Catch { Throw "EST profile set, but unable to enable HTTPS-only on $($Device): $_" } Initialize-AxisCache -Device $Device $Cache.$Device.Type = "https" } #Read back the status (UNCONNECTED until the device has talked to the server) $Status = $null Try { $Response = Invoke-AxisWebApi -Device $Device -Path "$BasePath/profiles/$EncodedId/status" $Status = [String]$Response.data } Catch { Write-Verbose "Could not read status of EST profile '$ProfileId': $_" } if(!$Status) { $Status = "UNKNOWN" } Write-Log -ID $Device -Message "$Action EST profile '$ProfileId' (server $EstServer, client cert '$ClientCertificateAlias', CA '$CaAlias', services $($Services -join ',')); status $Status" [pscustomobject]@{ Device = $Device ProfileId = $ProfileId Server = $EstServer Status = $Status } } |