diff --git a/Docs/HttpBootProfile-Reference.md b/Docs/HttpBootProfile-Reference.md index 68457b1..aba1bde 100644 --- a/Docs/HttpBootProfile-Reference.md +++ b/Docs/HttpBootProfile-Reference.md @@ -20,7 +20,7 @@ The script writes `\HttpBootProfile.xml` (UTF-8, no BOM): sha256 - + aca902cd0efc5d357e2400db6fd4a760799061147147906a61a2ca0bb47ffea6 @@ -28,6 +28,11 @@ The script writes `\HttpBootProfile.xml` (UTF-8, no BOM): The document is built with an `XmlDocument` and written through an `XmlWriter` (two-space indentation, UTF-8 without BOM). +### Hardware verified field constraints (CCTK 5.2.2, applied via `--Set` on a live BIOS) + +- **The `cert` field accepts a maximum of 2047 characters.** Only a single certificate fits (a 4096-bit RSA root like ISRG Root X1 is ~1,940 characters). Embedding a chain bundle (e.g. intermediates + root, "everything except the leaf") is rejected with exit 150 and the message `field certificate max allowed characters are 2047`. This is why the script embeds only the root. +- **`IntegrityInfo` and a non-empty `Digest` are mandatory.** A profile with an empty `` or without the `IntegrityInfo` element is rejected with exit 157, `some or all fields missing`. + Element notes (informed by the `BiosConnectProfile.xsd` schema that ships inside Dell Command | Configure): | Element | Notes | @@ -35,9 +40,9 @@ Element notes (informed by the `BiosConnectProfile.xsd` schema that ships inside | `UrlInfo@Type` | `https` or `http`, derived from the boot URL scheme | | `Url` | The full boot image URL | | `CertInfo` | Optional per the schema. Only emitted for `https` URLs. `Type="pem"` is the only supported certificate encoding | -| `cert` | The PEM encoded CA **root** certificate of the chain that signs the boot server's TLS certificate. For Let's Encrypt this is **ISRG Root X1** - not the leaf and not the R10/R11 intermediates | -| `IntegrityInfo` | **Required** by the schema (it cannot be omitted). `Algorithm` supports `sha1` and `sha256` | -| `Digest` | Intentionally left empty (the schema allows an empty value). This script places configuration values only - it does not download or hash the boot image, so the boot image on the web server can change freely without invalidating the profile | +| `cert` | The PEM encoded CA **root** certificate of the chain that signs the boot server's TLS certificate. For Let's Encrypt this is **ISRG Root X1** - not the leaf and not the R10/R11 intermediates. By default the script retrieves the chain directly from the boot endpoint (`Get-EndpointCertificateChain`) and embeds its self-signed root; the full endpoint chain is also exported to `BootEndpointCertificateChain.pem` within the staging directory. **The BIOS requires an RSA certificate** - importing an ECDSA certificate fails with a "not RSA format" error, so the script warns (but still embeds) when the root is not RSA | +| `IntegrityInfo` | **Required** - by the schema and by the BIOS (hardware verified: omitting it fails with exit 157). `Algorithm` supports `sha1` and `sha256` | +| `Digest` | **Required non-empty** (hardware verified: an empty value fails with exit 157). Computed from the downloaded boot image by default, or placed directly via `-BootImageDigest`. The BIOS enforces it against the boot image at boot time: if the file on the web server changes (e.g. a DeployR/2PXE upgrade replaces `snponly_x64.efi`), HTTP boot fails until the profile is re-applied with the new digest | | `SignValue` | Left empty (unsigned profile) | ## CCTK command sequence @@ -66,13 +71,15 @@ CCTK exit codes are documented by Dell at \CCTK` folder first (the toolkit resolves `X86`, `X64`, or `ARM64` automatically), then the process path, then the standard installation directories. -2. **Download the root certificate** - the Let's Encrypt "ISRG Root X1" PEM by default, so the BIOS can validate the TLS certificate presented by the boot server. -3. **Generate the HTTP boot profile XML** - built with an XmlDocument and written through an XmlWriter. The integrity digest is intentionally left empty: the script places configuration values only and does not download or hash the boot image. See [Docs/HttpBootProfile-Reference.md](Docs/HttpBootProfile-Reference.md) for the document format. -4. **Apply the BIOS configuration** by executing the following CCTK commands in order: +2. **Determine the root certificate** - when `-RootCertificateURL` is not explicitly specified, the `Get-EndpointCertificateChain` toolkit function performs a direct TLS handshake against the boot endpoint (exactly what the firmware will do - no proxy), captures the presented certificate chain, exports it to PEM (the full chain is written to the staging directory), and embeds the chain's self-signed root into the profile. If the endpoint cannot be reached, it gracefully falls back to downloading `-RootCertificateURL` (Let's Encrypt "ISRG Root X1" by default). Dell BIOS certificate import requires RSA - non-RSA (e.g. ECDSA) certificates are still exported and embedded, with a warning. +3. **Determine the boot image digest** - the BIOS requires a non-empty SHA-256 digest in the profile (hardware verified: an empty digest is rejected with exit 157 "some or all fields missing"), so the boot image is downloaded and hashed - unless a precomputed value is supplied with `-BootImageDigest`, which places the value without downloading anything. +4. **Generate the HTTP boot profile XML** - built with an XmlDocument and written through an XmlWriter. See [Docs/HttpBootProfile-Reference.md](Docs/HttpBootProfile-Reference.md) for the document format and the hardware verified field constraints (single certificate only - the BIOS cert field is capped at 2047 characters, so a chain bundle cannot be embedded). +5. **Apply the BIOS configuration** by executing the following CCTK commands in order: | # | Command | Notes | | --- | --- | --- | @@ -54,10 +55,11 @@ powershell.exe -ExecutionPolicy Bypass -NoProfile -NoLogo -File ".\Invoke-HTTPBo | Parameter | Default | Description | | --- | --- | --- | | `-BootURL` | (required) | Fully qualified HTTP(s) URL of the UEFI boot image, e.g. `https://prod.ipxe.example.com/2PXE/boot/x64/snponly_x64.efi`. When the URL does not end with a file name, `snponly_x64.efi` is appended automatically | -| `-RootCertificateURL` | Let's Encrypt ISRG Root X1 (`https://letsencrypt.org/certs/isrgrootx1.pem`) | PEM encoded CA root certificate the BIOS uses to validate the boot server TLS certificate | +| `-RootCertificateURL` | Let's Encrypt ISRG Root X1 (`https://letsencrypt.org/certs/isrgrootx1.pem`) | PEM encoded CA root certificate the BIOS uses to validate the boot server TLS certificate. When NOT explicitly specified, the chain is fetched directly from the boot endpoint instead, and this URL is only the graceful fallback; specifying it skips the endpoint retrieval | | `-CCTKDownloadURL` | Dell Command \| Configure 5.2.2 DUP on `dl.dell.com` | Source for the dynamic CCTK acquisition. Supports `.exe` (DUP), `.zip`, and `.7z` | | `-SevenZipDownloadURL` | `https://www.7-zip.org/a/7zr.exe` | Portable 7-Zip console executable used for payload extraction | | `-StagingDirectory` | `%WINDIR%\Temp\HTTPBootBios` | Working directory for downloads and extraction. Keep it short (MSI extraction fails on deep paths) | +| `-BootImageDigest` | (computed) | Precomputed SHA-256 of the boot image (64 hex chars), placed into the profile without downloading the boot image. When omitted, the boot image is downloaded and hashed. The BIOS enforces the digest at boot time - a stale value stops HTTP boot until the profile is re-applied | | `-SetupPassword` | (none) | BIOS setup password, appended as `--ValSetupPwd=` on modification commands. Safe to supply fleet-wide: CCTK ignores the argument on devices with no setup password installed (verified on 5.2.2) | | `-SkipProfileDeletion` | off | Do not delete the existing HTTP boot profile before applying | | `-LogDirectory` | auto (toolkit) | Log folder override | diff --git a/Toolkit/Functions/Get-EndpointCertificateChain.ps1 b/Toolkit/Functions/Get-EndpointCertificateChain.ps1 new file mode 100644 index 0000000..4738bcd --- /dev/null +++ b/Toolkit/Functions/Get-EndpointCertificateChain.ps1 @@ -0,0 +1,382 @@ +#region Get-EndpointCertificateChain +Function Get-EndpointCertificateChain + { + <# + .SYNOPSIS + Retrieves the TLS certificate chain presented by an HTTPS endpoint and exports it to PEM format. + + .DESCRIPTION + A synchronous TLS handshake is performed directly against the endpoint (TcpClient and SslStream) and the certificate chain is captured within the certificate validation callback. Validation always succeeds for the purposes of the capture, so untrusted, expired, or self signed endpoints can still be harvested. Each certificate within the chain is converted to PEM format by using the X509 classes, and the full chain can optionally be written to a PEM file. + + The connection is deliberately made directly (never through a proxy), because the BIOS HTTP(s) boot feature contacts the endpoint directly as well - the probe therefore represents exactly what the firmware will experience. The handshake is performed synchronously so that the capture callback executes on the calling thread, which is required for it to work within both Windows PowerShell 5.1 and PowerShell 7. + + The chain is returned leaf first. The root certificate is the last self signed certificate within the chain; when the chain does not resolve to a self signed certificate (for example when the issuing root is not present within the local certificate store), the topmost available certificate is returned as the root with a warning. + + Dell BIOS HTTP boot profile certificate import requires RSA certificates. Certificates that do not use an RSA public key (for example ECDSA) are logged with a warning, but they are still converted and exported. + + .PARAMETER URL + The HTTPS URL of the endpoint to retrieve the certificate chain from. Only the scheme, host, and port are relevant - the path is not downloaded. + + .PARAMETER Timeout + The maximum duration to wait for the endpoint connection and handshake. Defaults to 15 seconds. + + .PARAMETER ExportPath + An optional file path that the full certificate chain is written to in PEM format (UTF-8 without a byte order mark). + + .PARAMETER ContinueOnError + Return a result object with the Succeeded property set to false instead of throwing a terminating error when the chain cannot be retrieved. + + .EXAMPLE + $GetEndpointCertificateChainResult = Get-EndpointCertificateChain -URL 'https://prod.ipxe.example.com/2PXE/boot/x64/snponly_x64.efi' -ExportPath 'C:\Windows\Temp\Chain.pem' -ContinueOnError + + Switch ($GetEndpointCertificateChainResult.Succeeded) + { + {($_ -eq $True)} + { + Write-Output -InputObject ($GetEndpointCertificateChainResult.RootCertificate.PEMContent) + } + } + + .NOTES + The returned object contains: Succeeded, URL, ChainList (one entry per certificate: Index, Subject, Issuer, Thumbprint, NotBefore, NotAfter, KeyAlgorithm, IsSelfSigned, PEMContent, Certificate), LeafCertificate, RootCertificate, FullChainPEM, and ExportPath. + + .LINK + https://learn.microsoft.com/dotnet/api/system.security.cryptography.x509certificates.x509certificate2 + #> + + [CmdletBinding()] + Param + ( + [Parameter(Mandatory=$True)] + [ValidateNotNullOrEmpty()] + [Alias('U')] + [System.URI]$URL, + + [Parameter(Mandatory=$False)] + [ValidateNotNullOrEmpty()] + [Alias('T')] + [System.TimeSpan]$Timeout, + + [Parameter(Mandatory=$False)] + [ValidateNotNullOrEmpty()] + [Alias('EP')] + [System.IO.FileInfo]$ExportPath, + + [Parameter(Mandatory=$False)] + [Alias('COE')] + [Switch]$ContinueOnError + ) + + Try + { + $ErrorActionPreference = 'Stop' + + [String]$CmdletName = $MyInvocation.MyCommand.Name + + $WriteLogMessage.Invoke(0, @("Function `'$($CmdletName)`' is beginning. Please Wait...")) + + #region Set default parameter values + Switch ($True) + { + {($Null -ieq $Timeout)} + { + [System.TimeSpan]$Timeout = [System.TimeSpan]::FromSeconds(15) + } + } + #endregion + + $OutputObjectProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' + $OutputObjectProperties.Succeeded = $False + $OutputObjectProperties.URL = $URL + $OutputObjectProperties.ChainList = New-Object -TypeName 'System.Collections.Generic.List[PSObject]' + $OutputObjectProperties.LeafCertificate = $Null + $OutputObjectProperties.RootCertificate = $Null + $OutputObjectProperties.FullChainPEM = [System.String]::Empty + $OutputObjectProperties.ExportPath = $Null + + Switch ($URL.Scheme -ieq 'https') + { + {($_ -eq $False)} + { + Throw "The URL scheme must be https in order to retrieve a TLS certificate chain. [URL: $($URL.AbsoluteUri)]" + } + } + + #region Define the PEM conversion scriptblock + [ScriptBlock]$ConvertToPEM = { + Param + ( + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + [String]$CertificateBase64 = [System.Convert]::ToBase64String($Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + + $PEMBuilder = New-Object -TypeName 'System.Text.StringBuilder' + $Null = $PEMBuilder.Append('-----BEGIN CERTIFICATE-----') + $Null = $PEMBuilder.Append("`n") + + For ($CertificateBase64Index = 0; $CertificateBase64Index -lt $CertificateBase64.Length; $CertificateBase64Index = $CertificateBase64Index + 64) + { + $Null = $PEMBuilder.Append($CertificateBase64.Substring($CertificateBase64Index, [System.Math]::Min(64, $CertificateBase64.Length - $CertificateBase64Index))) + $Null = $PEMBuilder.Append("`n") + } + + $Null = $PEMBuilder.Append('-----END CERTIFICATE-----') + + Write-Output -InputObject ($PEMBuilder.ToString()) + } + #endregion + + #region Perform the TLS handshake and capture the certificate chain + $ChainCaptureList = New-Object -TypeName 'System.Collections.Generic.List[System.Security.Cryptography.X509Certificates.X509Certificate2]' + + #The callback always returns true so that untrusted, expired, or self signed endpoints can still be harvested. The certificates are cloned from their raw data so that they remain usable after the handshake completes. The closure is created within an isolated child scope so that only the capture list is captured, because within Windows PowerShell 5.1, GetNewClosure() clones every local variable including unbound validated parameters, which otherwise fails validation. + [System.Net.Security.RemoteCertificateValidationCallback]$CertificateCaptureCallback = & { + Param + ( + $CaptureList + ) + + #The closure is emitted as a bare expression, because Write-Output treats a script block argument as a delay-bind block and refuses it without pipeline input. + ({ + Param + ( + $SenderObject, + $Certificate, + $Chain, + $SslPolicyErrors + ) + + Try + { + Switch (($Null -ine $Chain) -and ($Chain.ChainElements.Count -gt 0)) + { + {($_ -eq $True)} + { + ForEach ($ChainElement In $Chain.ChainElements) + { + $CaptureList.Add((New-Object -TypeName 'System.Security.Cryptography.X509Certificates.X509Certificate2' -ArgumentList @(, $ChainElement.Certificate.RawData))) + } + } + + Default + { + Switch ($Null -ine $Certificate) + { + {($_ -eq $True)} + { + $CaptureList.Add((New-Object -TypeName 'System.Security.Cryptography.X509Certificates.X509Certificate2' -ArgumentList @(, $Certificate.GetRawCertData()))) + } + } + } + } + } + Catch + { + + } + + Write-Output -InputObject ($True) + }.GetNewClosure()) + } $ChainCaptureList + + + $WriteLogMessage.Invoke(0, @("Attempting to retrieve the TLS certificate chain from `"$($URL.Host):$($URL.Port)`". Please Wait...")) + + $TcpClient = New-Object -TypeName 'System.Net.Sockets.TcpClient' + $TcpClient.ReceiveTimeout = $Timeout.TotalMilliseconds + $TcpClient.SendTimeout = $Timeout.TotalMilliseconds + + $SslStream = $Null + + Try + { + $TcpClientConnectTask = $TcpClient.ConnectAsync($URL.Host, $URL.Port) + + Switch ($TcpClientConnectTask.Wait($Timeout.TotalMilliseconds)) + { + {($_ -eq $False)} + { + Throw "A TCP connection to `"$($URL.Host):$($URL.Port)`" could not be established within $($Timeout.TotalSeconds) second(s)." + } + } + + #The handshake is performed synchronously so that the capture callback executes on the calling thread, which is required within both Windows PowerShell 5.1 and PowerShell 7. + $SslStream = New-Object -TypeName 'System.Net.Security.SslStream' -ArgumentList @($TcpClient.GetStream(), $False, $CertificateCaptureCallback) + $SslStream.ReadTimeout = $Timeout.TotalMilliseconds + $SslStream.WriteTimeout = $Timeout.TotalMilliseconds + + $Null = $SslStream.AuthenticateAsClient($URL.Host) + } + Finally + { + Switch ($Null -ine $SslStream) + { + {($_ -eq $True)} + { + $Null = $SslStream.Dispose() + } + } + + $Null = $TcpClient.Dispose() + } + + Switch ($ChainCaptureList.Count -gt 0) + { + {($_ -eq $False)} + { + Throw "A TLS certificate chain could not be retrieved from `"$($URL.Scheme)://$($URL.Authority)`". The endpoint may be unreachable." + } + } + #endregion + + #region Convert the captured certificate chain to PEM format + $KeyAlgorithmOIDTable = New-Object -TypeName 'System.Collections.Generic.Dictionary[[String], [String]]' + $KeyAlgorithmOIDTable.'1.2.840.113549.1.1.1' = 'RSA' + $KeyAlgorithmOIDTable.'1.2.840.10045.2.1' = 'ECDSA' + $KeyAlgorithmOIDTable.'1.2.840.10040.4.1' = 'DSA' + + $FullChainPEMBuilder = New-Object -TypeName 'System.Text.StringBuilder' + + For ($ChainCaptureListIndex = 0; $ChainCaptureListIndex -lt $ChainCaptureList.Count; $ChainCaptureListIndex++) + { + $ChainCertificate = $ChainCaptureList[$ChainCaptureListIndex] + + [String]$ChainCertificateKeyAlgorithm = "$($ChainCertificate.PublicKey.Oid.Value)" + + Switch ($KeyAlgorithmOIDTable.ContainsKey($ChainCertificateKeyAlgorithm)) + { + {($_ -eq $True)} + { + [String]$ChainCertificateKeyAlgorithm = $KeyAlgorithmOIDTable[$ChainCertificateKeyAlgorithm] + } + } + + $ChainEntryProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' + $ChainEntryProperties.Index = $ChainCaptureListIndex + $ChainEntryProperties.Subject = "$($ChainCertificate.Subject)" + $ChainEntryProperties.Issuer = "$($ChainCertificate.Issuer)" + $ChainEntryProperties.Thumbprint = "$($ChainCertificate.Thumbprint)" + $ChainEntryProperties.NotBefore = $ChainCertificate.NotBefore + $ChainEntryProperties.NotAfter = $ChainCertificate.NotAfter + $ChainEntryProperties.KeyAlgorithm = $ChainCertificateKeyAlgorithm + $ChainEntryProperties.IsSelfSigned = ($ChainCertificate.Subject -ieq $ChainCertificate.Issuer) + $ChainEntryProperties.PEMContent = $ConvertToPEM.InvokeReturnAsIs($ChainCertificate) + $ChainEntryProperties.Certificate = $ChainCertificate + + $ChainEntry = New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ChainEntryProperties) + + $OutputObjectProperties.ChainList.Add($ChainEntry) + + $Null = $FullChainPEMBuilder.Append($ChainEntry.PEMContent) + $Null = $FullChainPEMBuilder.Append("`n") + + $WriteLogMessage.Invoke(0, @("Chain certificate $($ChainCaptureListIndex + 1) of $($ChainCaptureList.Count): [Subject: $($ChainEntry.Subject)] [Issuer: $($ChainEntry.Issuer)] [Key Algorithm: $($ChainEntry.KeyAlgorithm)] [Self Signed: $($ChainEntry.IsSelfSigned)] [Expiration: $($ChainEntry.NotAfter.ToString('o'))] [Thumbprint: $($ChainEntry.Thumbprint)]")) + + Switch ($True) + { + {($ChainEntry.KeyAlgorithm -ine 'RSA')} + { + $WriteLogMessage.Invoke(2, @("The certificate `"$($ChainEntry.Subject)`" uses the `"$($ChainEntry.KeyAlgorithm)`" key algorithm.", "Dell BIOS HTTP boot profile certificate import requires RSA certificates, so the BIOS may reject this certificate with a `"not RSA format`" error. The certificate will still be exported.")) + } + + {($ChainEntry.NotAfter -lt (Get-Date))} + { + $WriteLogMessage.Invoke(2, @("The certificate `"$($ChainEntry.Subject)`" has expired. [Expiration: $($ChainEntry.NotAfter.ToString('o'))]")) + } + } + } + + $OutputObjectProperties.FullChainPEM = $FullChainPEMBuilder.ToString() + + $OutputObjectProperties.LeafCertificate = $OutputObjectProperties.ChainList[0] + #endregion + + #region Determine the root certificate (The last self signed certificate within the chain) + For ($ChainListIndex = $OutputObjectProperties.ChainList.Count - 1; $ChainListIndex -ge 0; $ChainListIndex--) + { + Switch (($Null -ieq $OutputObjectProperties.RootCertificate) -and ($OutputObjectProperties.ChainList[$ChainListIndex].IsSelfSigned -eq $True)) + { + {($_ -eq $True)} + { + $OutputObjectProperties.RootCertificate = $OutputObjectProperties.ChainList[$ChainListIndex] + } + } + } + + Switch ($Null -ieq $OutputObjectProperties.RootCertificate) + { + {($_ -eq $True)} + { + $OutputObjectProperties.RootCertificate = $OutputObjectProperties.ChainList[$OutputObjectProperties.ChainList.Count - 1] + + $WriteLogMessage.Invoke(2, @("The retrieved certificate chain does not resolve to a self signed root certificate. The topmost available certificate will be used instead, which may stop working when the certificate authority rotates its intermediate certificates. [Subject: $($OutputObjectProperties.RootCertificate.Subject)]")) + } + } + + $WriteLogMessage.Invoke(0, @("Root Certificate: [Subject: $($OutputObjectProperties.RootCertificate.Subject)] [Key Algorithm: $($OutputObjectProperties.RootCertificate.KeyAlgorithm)] [Thumbprint: $($OutputObjectProperties.RootCertificate.Thumbprint)]")) + #endregion + + #region Export the full certificate chain to a PEM file (When requested) + Switch (([System.String]::IsNullOrEmpty($ExportPath) -eq $False) -and ([System.String]::IsNullOrWhiteSpace($ExportPath) -eq $False)) + { + {($_ -eq $True)} + { + Switch ([System.IO.Directory]::Exists($ExportPath.Directory.FullName)) + { + {($_ -eq $False)} + { + $Null = [System.IO.Directory]::CreateDirectory($ExportPath.Directory.FullName) + } + } + + $WriteLogMessage.Invoke(0, @("Attempting to export the full certificate chain to `"$($ExportPath.FullName)`". Please Wait...")) + + $Null = [System.IO.File]::WriteAllText("$($ExportPath.FullName)", $OutputObjectProperties.FullChainPEM, (New-Object -TypeName 'System.Text.UTF8Encoding' -ArgumentList @($False))) + + $OutputObjectProperties.ExportPath = $ExportPath + } + } + #endregion + + $OutputObjectProperties.Succeeded = $True + + Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($OutputObjectProperties)) + } + Catch + { + $ExceptionPropertyDictionary = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' + $ExceptionPropertyDictionary.Add('Message', $_.Exception.Message) + $ExceptionPropertyDictionary.Add('Category', $_.Exception.ErrorRecord.FullyQualifiedErrorID) + $ExceptionPropertyDictionary.Add('LineNumber', $_.InvocationInfo.ScriptLineNumber) + $ExceptionPropertyDictionary.Add('LinePosition', $_.InvocationInfo.OffsetInLine) + $ExceptionPropertyDictionary.Add('Code', $_.InvocationInfo.Line.Trim()) + + $ExceptionMessageList = New-Object -TypeName 'System.Collections.Generic.List[String]' + + ForEach ($ExceptionProperty In $ExceptionPropertyDictionary.GetEnumerator()) + { + $ExceptionMessageList.Add("[$($ExceptionProperty.Key): $($ExceptionProperty.Value)]") + } + + $WriteLogMessage.Invoke(2, @("$($ExceptionMessageList -Join ' ')")) + + Switch ($ContinueOnError.IsPresent) + { + {($_ -eq $True)} + { + Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($OutputObjectProperties)) + } + + Default + { + Throw + } + } + } + Finally + { + $WriteLogMessage.Invoke(0, @("Function `'$($CmdletName)`' is completed.")) + } + } +#endregion