diff --git a/AxisIPCamera/AxisIPCamera.csproj b/AxisIPCamera/AxisIPCamera.csproj
index 020f565..13b30a3 100644
--- a/AxisIPCamera/AxisIPCamera.csproj
+++ b/AxisIPCamera/AxisIPCamera.csproj
@@ -2,47 +2,54 @@
true
- net6.0;net8.0
+ net8.0
true
disable
Keyfactor.Extensions.Orchestrator.AxisIPCamera
+ 1.1.0
-
-
-
-
- Always
-
-
-
-
-
-
-
- Always
-
-
-
- Always
-
-
-
- Always
-
-
-
- Always
-
-
-
- Always
-
-
-
- Always
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+ Always
+
+
+
+ Always
+
+
+
+ Always
+
+
+
+ Always
+
+
+
+ Always
+
+
+
+ Always
+
diff --git a/AxisIPCamera/Client/AxisHttpClient.cs b/AxisIPCamera/Client/AxisHttpClient.cs
index 7f594dc..103f3cd 100644
--- a/AxisIPCamera/Client/AxisHttpClient.cs
+++ b/AxisIPCamera/Client/AxisHttpClient.cs
@@ -1,4 +1,4 @@
-// Copyright 2025 Keyfactor
+// Copyright 2026 Keyfactor
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
@@ -524,30 +524,79 @@ public void RemoveCACertificate(string alias)
Logger.LogError($"HTTP Request unsuccessful - HTTP Response: {DecodeHttpStatus(httpResponse)}");
throw new Exception($"HTTP Request unsuccessful.");
}
+
// Decode the API response when HTTP response is successful
+ if (httpResponse != null && string.IsNullOrEmpty(httpResponse.Content))
+ {
+ throw new Exception("No content returned from HTTP Response");
+ }
+
+ RestApiResponse apiResponse = JsonConvert.DeserializeObject(httpResponse.Content);
+ if (apiResponse.Status == Constants.Status.Success)
+ {
+ Logger.MethodExit();
+ }
else
{
- if (httpResponse != null && string.IsNullOrEmpty(httpResponse.Content))
- {
- throw new Exception("No content returned from HTTP Response");
- }
+ ErrorData error = JsonConvert.DeserializeObject(httpResponse.Content);
+ throw new Exception(
+ $"API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})");
+ }
+ }
+ catch (Exception e)
+ {
+ Logger.LogError("Error completing CA certificate remove: " + LogHandler.FlattenException(e));
+ throw new Exception(e.Message);
+ }
+ }
+
+ ///
+ /// Removes a certificate with private key from the device.
+ ///
+ /// Unique identifier of the CA certificate to be removed
+ public HttpResult RemoveCertificate(string alias)
+ {
+ try
+ {
+ Logger.MethodEntry();
+ var context = new HttpContext();
+
+ var deleteCertResource = $"{Constants.RestApiEntryPoint}/certificates/{alias}";
+ var httpResponse = ExecuteHttp(deleteCertResource, Method.Delete);
+
+ // Decode the HTTP response if failed
+ if (httpResponse is { IsSuccessful: false })
+ {
+ var decodedStatus = DecodeHttpStatus(httpResponse);
+
+ Logger.LogWarning($"HTTP Request unsuccessful - HTTP Response: {decodedStatus}");
+ context.AddWarning(decodedStatus);
+ }
+
+ // Decode the API response for more information
+ if (httpResponse != null && string.IsNullOrEmpty(httpResponse.Content))
+ {
+ Logger.LogError("No content returned from HTTP Response");
+ context.AddError($"No content returned from HTTP Response for {nameof(Method.Delete)} {deleteCertResource}");
+ }
+ else
+ {
RestApiResponse apiResponse = JsonConvert.DeserializeObject(httpResponse.Content);
- if (apiResponse.Status == Constants.Status.Success)
- {
- Logger.MethodExit();
- }
- else
+ if (apiResponse.Status != Constants.Status.Success)
{
ErrorData error = JsonConvert.DeserializeObject(httpResponse.Content);
- throw new Exception(
- $"API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})");
+ Logger.LogWarning($"API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})");
+ context.AddWarning($"HTTP Request {nameof(Method.Delete)} {deleteCertResource}: API error encountered - {error.ErrorInfo.Message} - (Code: {error.ErrorInfo.Code})");
}
}
+
+ Logger.MethodExit();
+ return context.ToResult();
}
catch (Exception e)
{
- Logger.LogError("Error completing CA certificate remove: " + LogHandler.FlattenException(e));
+ Logger.LogError("Error completing certificate remove: " + LogHandler.FlattenException(e));
throw new Exception(e.Message);
}
}
diff --git a/AxisIPCamera/Helpers/DeviceCertValidator.cs b/AxisIPCamera/Helpers/DeviceCertValidator.cs
index f24a662..f705a04 100644
--- a/AxisIPCamera/Helpers/DeviceCertValidator.cs
+++ b/AxisIPCamera/Helpers/DeviceCertValidator.cs
@@ -73,6 +73,12 @@ public static class DeviceCertValidator
// Add TLS cert as leaf certificate to the end of the custom chain
customChain.Add(parser.ReadCertificate(cert.RawData));
+
+ if (!File.Exists(trustedIntCertPath))
+ {
+ logger.LogError($"{trustedIntCertPath} does not exist.");
+ return false;
+ }
logger.LogTrace($"Loading Trusted Intermediate Certs from {trustedIntCertPath}");
var trustedIntCerts = parser.ReadCertificates(File.ReadAllBytes(trustedIntCertPath));
@@ -92,6 +98,12 @@ public static class DeviceCertValidator
logger.LogTrace($"{trustedIntCerts.Count} Trusted Intermediate Certs found");
+ if (!File.Exists(trustedRootCertPath))
+ {
+ logger.LogError($"{trustedRootCertPath} does not exist.");
+ return false;
+ }
+
logger.LogTrace($"Loading Trusted Root Cert from {trustedRootCertPath}");
var trustedRootCerts = parser.ReadCertificates(File.ReadAllBytes(trustedRootCertPath));
@@ -214,8 +226,8 @@ private static bool VerifyAkiSkiChain(List customChain, ILogger
{
logger.MethodEntry();
- logger.LogTrace("Custom chain being validated includes: (1) Leaf cert from TLS session, (2) n-Intermediate certs from custom trust, &" +
- "n-Root certs from custom trust");
+ logger.LogTrace("Custom chain being validated includes: (1) Leaf cert from TLS session, (2) n-Intermediate certs from custom trust, & " +
+ "(3) n-Root certs from custom trust");
for (int i = 0; i < customChain.Count - 1; i++)
{
diff --git a/AxisIPCamera/Helpers/SANBuilder.cs b/AxisIPCamera/Helpers/SANBuilder.cs
new file mode 100644
index 0000000..5b569d8
--- /dev/null
+++ b/AxisIPCamera/Helpers/SANBuilder.cs
@@ -0,0 +1,64 @@
+// Copyright 2026 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
+// and limitations under the License.
+
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Extensions.Logging;
+
+namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera.Helpers
+{
+ public static class SANBuilder
+ {
+ public static List BuildSANList(Dictionary sans, ILogger logger)
+ {
+ var parts = new List();
+
+ if (sans == null || sans.Count == 0)
+ {
+ logger.LogTrace($"SANs is null or empty");
+ return parts;
+ }
+
+ foreach (var entry in sans)
+ {
+ string key = NormalizeSanKey(entry.Key);
+
+ // The Axis API only supports the addition of 'dns' and 'ip' SAN type keys
+ if (key is not ("DNS" or "IP"))
+ continue;
+
+ if (entry.Value == null || entry.Value.Length == 0)
+ continue;
+
+ // NOTE: We are separating the key and value pairs with a colon because this is the format
+ // required to send SANs to the Axis API endpoint
+ parts.AddRange(
+ entry.Value
+ .Where(v => !string.IsNullOrWhiteSpace(v))
+ .Select(v => $"{key}:{v.Trim()}")
+ );
+ }
+
+ return parts;
+ }
+
+ ///
+ /// Normalize SAN type keys to RFC-compliant names.
+ /// **NOTE: The Axis API only supports the addition of 'dns' and 'ip' SAN types.
+ /// Courtesy of B.Pokorny.
+ ///
+ private static string NormalizeSanKey(string key)
+ {
+ return key.Trim().ToLower() switch
+ {
+ "dns" => "DNS",
+ "ip" or "ip4" or "ip6" => "IP",
+ _ => key.ToLower() // default
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/AxisIPCamera/Inventory.cs b/AxisIPCamera/Inventory.cs
index ca579c9..278eb3c 100644
--- a/AxisIPCamera/Inventory.cs
+++ b/AxisIPCamera/Inventory.cs
@@ -62,8 +62,9 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd
_logger.LogTrace("Retrieve all client certificates");
CertificateData data2 = client.ListCertificates();
+ // TODO: Remove this if not using
// Get the default keystore
- _logger.LogTrace("Retrieve the default keystore");
+ /*_logger.LogTrace("Retrieve the default keystore");
Constants.Keystore defaultKeystore = client.GetDefaultKeystore();
string defaultKeystoreString = defaultKeystore.ToString();
_logger.LogDebug($"Inventory - Default keystore: {defaultKeystoreString}");
@@ -78,7 +79,7 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd
foreach (var cert in data2.Certs.Where(cert => cert.Keystore == defaultKeystore))
{
data2DefKey.Certs.Add(cert);
- }
+ }*/
_logger.LogTrace("Retrieve all certificate bindings for each possible certificate usage type");
// Lookup the certificate used for HTTPS, MQTT, IEEE802.X
@@ -88,7 +89,7 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd
// Set the binding on the client certificates object if the aliases found for each certificate usage match
_logger.LogTrace("Mark each client certificate with the appropriate certificate usage type");
- foreach (Certificate c in data2DefKey.Certs)
+ foreach (Certificate c in data2.Certs)
{
if (c.Alias.Equals(httpAlias))
{
@@ -131,7 +132,7 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd
}).Where(item => item?.Certificates != null).ToList());
// Build the list of client certificates and add to the InventoryItems object that is sent back to Command
- inventoryItems.AddRange(data2DefKey.Certs.Select(
+ inventoryItems.AddRange(data2.Certs.Select(
c =>
{
try
diff --git a/AxisIPCamera/Model/Constants.cs b/AxisIPCamera/Model/Constants.cs
index 2f182e0..9a0f9ad 100644
--- a/AxisIPCamera/Model/Constants.cs
+++ b/AxisIPCamera/Model/Constants.cs
@@ -1,4 +1,4 @@
-// Copyright 2025 Keyfactor
+// Copyright 2026 Keyfactor
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
@@ -6,7 +6,9 @@
// and limitations under the License.
using System;
+using System.Globalization;
using System.IO;
+using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Pkcs;
@@ -16,7 +18,7 @@ namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera.Model
public static class Constants
{
// This is the API entry point for the REST VAPIX Cert Management API
- public static string RestApiEntryPoint = "/config/rest/cert/v1beta";
+ public static string RestApiEntryPoint = "/config/rest/cert/v1";
// This is the API entry point for the SOAP Cert Management API
public static string SoapApiEntryPoint = "/vapix/services";
@@ -248,4 +250,31 @@ public override void WriteJson(
}
}
}
+
+ public static class CertificateName
+ {
+ ///
+ /// Returns a UTC-based suffix, i.e. "2602171544"
+ ///
+ public static string GetUtcSuffix() =>
+ DateTime.UtcNow.ToString("yyMMddHHmm", CultureInfo.InvariantCulture);
+
+ ///
+ /// Creates a unique certificate name by appending ['_' + Utc DateTime suffix] to the end of the user-supplied certificate name.
+ /// Example: "_2602171544"
+ ///
+ public static string CreateUniqueCertName(string certName)
+ {
+ // check to see if the old cert name had a previously appended timestamp
+ // EDGE CASE: Scenario under which this could happen - Cert name bound to usage is known and used to schedule an ODKG job
+ Regex rgx = new Regex(@"_[0-9]{10}$",RegexOptions.CultureInvariant);
+ var m = Regex.Match(certName,@"_[0-9]{10}$");
+ if (m.Success)
+ {
+ return certName.Remove(m.Index, m.Length) + "_" + GetUtcSuffix();
+ }
+
+ return certName + "_" + GetUtcSuffix();
+ }
+ }
}
diff --git a/AxisIPCamera/Model/HttpResponse.cs b/AxisIPCamera/Model/HttpResponse.cs
new file mode 100644
index 0000000..d1a0441
--- /dev/null
+++ b/AxisIPCamera/Model/HttpResponse.cs
@@ -0,0 +1,68 @@
+// Copyright 2026 Keyfactor
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
+// and limitations under the License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera.Model
+{
+ public sealed class HttpContext
+ {
+ private readonly List _warnings = new();
+ private readonly List _errors = new();
+
+ public IReadOnlyList Warnings => _warnings;
+ public IReadOnlyList Errors => _errors;
+
+ public void AddWarning(string message) =>
+ _warnings.Add(message);
+
+ public void AddError(string message) =>
+ _errors.Add(message);
+
+ public HttpResult ToResult()
+ {
+ if (_errors.Any())
+ return HttpResult.Error(FormatMessages(_errors));
+
+ if (_warnings.Any())
+ return HttpResult.Warning(FormatMessages(_warnings));
+
+ return HttpResult.Success();
+ }
+
+ private static string FormatMessages(IEnumerable messages)
+ {
+ return string.Join(
+ Environment.NewLine,
+ messages.Select((message, index) =>
+ $"({index + 1}) {message}")
+ );
+ }
+ }
+
+ public enum HttpStatus
+ {
+ Success,
+ Warning,
+ Error
+ }
+
+ public sealed record HttpResult(HttpStatus Status, string Message = null)
+ {
+ public static HttpResult Success()
+ => new(HttpStatus.Success);
+
+ public static HttpResult Warning(string message)
+ => new(HttpStatus.Warning, message);
+
+ public static HttpResult Error(string message)
+ => new(HttpStatus.Error, message);
+
+ }
+}
\ No newline at end of file
diff --git a/AxisIPCamera/Reenrollment.cs b/AxisIPCamera/Reenrollment.cs
index afd519b..b411815 100644
--- a/AxisIPCamera/Reenrollment.cs
+++ b/AxisIPCamera/Reenrollment.cs
@@ -1,4 +1,4 @@
-// Copyright 2025 Keyfactor
+// Copyright 2026 Keyfactor
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
@@ -7,6 +7,8 @@
using System;
using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
@@ -14,10 +16,14 @@
using Keyfactor.Logging;
using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Client;
+using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Helpers;
using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Model;
using Keyfactor.Orchestrators.Common.Enums;
using Keyfactor.Orchestrators.Extensions;
using Keyfactor.Orchestrators.Extensions.Interfaces;
+using Keyfactor.PKI.Enums;
+using Keyfactor.PKI.X509;
+//using Org.BouncyCastle.X509;
namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera
{
@@ -25,7 +31,7 @@ public class Reenrollment : IReenrollmentJobExtension
{
private readonly ILogger _logger;
- private readonly IPAMSecretResolver _resolver;
+ private readonly IPAMSecretResolver _resolver;
public string ExtensionName => "";
public Reenrollment(IPAMSecretResolver resolver)
@@ -53,14 +59,30 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm
}
_logger.LogDebug("--- End Job Properties");
+ // Log each SAN, if provided
+ _logger.LogDebug("Begin SANs ---");
+ var formattedSANs = SANBuilder.BuildSANList(config.SANs,_logger);
+ if (formattedSANs.Count == 0)
+ {
+ _logger.LogDebug($"No SAN values found.");
+ }
+ else
+ {
+ foreach (var san in formattedSANs)
+ {
+ _logger.LogDebug($"{san}");
+ }
+ }
+ _logger.LogDebug("--- End SANs");
+
// Get required reenrollment fields
string certUsage = config.JobProperties[Constants.CertUsageParamName].ToString() ?? throw new Exception($"{Constants.CertUsageParamDisplay} returned null");
var certUsageEnum = Constants.GetCertUsageAsEnum(certUsage);
string keyAlgorithm = config.JobProperties["keyType"].ToString() ?? throw new Exception("Key Algorithm returned null");
string keySize = config.JobProperties["keySize"].ToString() ?? throw new Exception("Key Size returned null");
string subject = config.JobProperties["subjectText"].ToString() ?? throw new Exception("Subject returned null");
- string reenrollAlias = config.Alias ?? throw new Exception("Alias returned null");
- _logger.LogDebug($"Alias: {reenrollAlias}");
+ string newAlias = config.Alias ?? throw new Exception("Alias returned null");
+ _logger.LogDebug($"Alias: {newAlias}");
// Prevent reenrollment on Trust certificates
if (certUsageEnum is Constants.CertificateUsage.Trust)
@@ -72,28 +94,27 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm
_logger.LogTrace("Create HTTPS client to connect to device");
var client = new AxisHttpClient(config, config.CertificateStoreDetails, _resolver);
- // Get current binding for reenrollment certificate usage provided
+ // Get the existing alias name associated with the supplied cert usage
_logger.LogTrace($"Check '{certUsage}' binding for same alias");
- var boundAlias = client.GetCertUsageBinding(Constants.GetCertUsageAsEnum(certUsage));
- if (!string.IsNullOrEmpty(boundAlias))
+ var oldAlias = client.GetCertUsageBinding(Constants.GetCertUsageAsEnum(certUsage));
+ var oldCertExists = false;
+ if (!string.IsNullOrEmpty(oldAlias))
{
- _logger.LogDebug($"Alias currently bound to certificate usage type '{certUsage}': {boundAlias}");
+ oldCertExists = true;
+ _logger.LogDebug($"Alias currently bound to certificate usage type '{certUsage}': {oldAlias}");
- if (boundAlias == reenrollAlias)
- {
- _logger.LogDebug($"Alias '{reenrollAlias}' provided for reenrollment matches alias '{boundAlias}' currently bound " +
- $"to certificate usage type {certUsage}");
-
- throw new Exception(
- $"Alias '{reenrollAlias}' already exists for certificate usage type {certUsage}. Reenroll using another alias.");
- }
-
- _logger.LogTrace($"Alias '{reenrollAlias}' provided for reenrollment differs from alias '{boundAlias}' currently bound " +
- $"to certificate usage type {certUsage}. Proceeding...");
+ // compare the old alias name with the new alias name ---
+ // 1) if the names are the same, append a reserved time-based suffix to the end of the name
+ // This new name [AliasA_Timestamp] will be used to create the new cert.
+ // OR
+ // 2) EDGE CASE: if the old alias name currently tied to the cert usage does NOT match the new alias name,
+ // also create a new name [CertB_Timestamp] for the new cert in case the user-supplied cert name is already
+ // associated with an existing certificate that is NOT bound to a cert usage
+ newAlias = CertificateName.CreateUniqueCertName(newAlias);
}
else
{
- _logger.LogDebug($"No alias currently bound to certificate usage type {certUsage}");
+ _logger.LogDebug($"No alias currently bound to certificate usage type {certUsage}. Proceeding with new key, CSR, and adding cert for new alias...");
}
// Map the key type and key size from the job properties to a corresponding key type available on the device
@@ -112,10 +133,10 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm
Constants.Keystore defaultKeystore = client.GetDefaultKeystore();
string defaultKeystoreString = defaultKeystore.ToString();
_logger.LogDebug($"Reenrollment - Default keystore: {defaultKeystoreString}");
-
- _logger.LogTrace("Generating self-signed cert with private key on device");
- List sansList = new List();
- if (certUsageEnum == Constants.CertificateUsage.Https)
+
+ // If no SANs are provided and the cert usage is 'HTTPS' ---
+ // Add 1 for DNS and 1 for IP address to eliminate TLS errors
+ if(formattedSANs.Count == 0 && certUsageEnum == Constants.CertificateUsage.Https)
{
_logger.LogTrace("Extracting CN and IP address to add as SANs to the certificate");
// Extract the CN from the Subject
@@ -126,8 +147,9 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm
throw new Exception(
"No value provided in the Subject for 'CN'. This is required for HTTPS certificates.");
}
+
_logger.LogTrace($"Extracted CN attribute from the Subject: {cnMatch.Groups[1].Value}");
-
+
// Extract the IP address from the Client Machine
var ipMatch = Regex.Match(config.CertificateStoreDetails.ClientMachine,
@"^(?(?:\d{1,3}\.){3}\d{1,3})", RegexOptions.IgnoreCase);
@@ -137,25 +159,28 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm
throw new Exception(
"Value provided for the Client Machine does not match IPv4 format.");
}
- _logger.LogTrace($"Extracted IP Address from the Client Machine: { ipMatch.Groups["ip"].Value}");
- sansList.Add("DNS:" + cnMatch.Groups[1].Value);
- sansList.Add("IP:" + ipMatch.Groups["ip"].Value);
+ _logger.LogTrace($"Extracted IP Address from the Client Machine: {ipMatch.Groups["ip"].Value}");
+
+ formattedSANs.Add($"DNS:{cnMatch.Groups[1].Value}");
+ formattedSANs.Add($"IP:{ipMatch.Groups["ip"].Value}");
}
- client.CreateSelfSignedCert(reenrollAlias,keyType,defaultKeystoreString,subject,sansList.ToArray());
- _logger.LogTrace("Obtaining CSR using self-signed certificate");
- var csr = client.ObtainCSR(reenrollAlias);
+ _logger.LogTrace("Generating private key pair on device");
+ client.CreateSelfSignedCert(newAlias,keyType,defaultKeystoreString,subject,formattedSANs.ToArray());
+
+ _logger.LogTrace("Obtaining CSR");
+ var csr = client.ObtainCSR(newAlias);
_logger.LogDebug($"CSR: \n{csr}");
-
+
_logger.LogTrace("Validating CSR");
Constants.ValidateCsr(csr);
_logger.LogTrace("CSR is valid");
- // Submit CSR to be signed in Keyfactor
- _logger.LogTrace("Submitting CSR to be signed in Command");
+ // Submit CSR to be signed
+ _logger.LogTrace("Submitting CSR to Command to enroll for signed certificate");
var x509Cert = submitReenrollment.Invoke(csr);
-
+
// Build PEM content
// ** NOTE: The static newline (\n) characters are required in the API request
StringBuilder pemBuilder = new StringBuilder();
@@ -165,12 +190,41 @@ public JobResult ProcessJob(ReenrollmentJobConfiguration config, SubmitReenrollm
pemBuilder.Append(noLineBreaks);
pemBuilder.Append(@"\n-----END CERTIFICATE-----");
var pemCert = pemBuilder.ToString();
+
+ _logger.LogTrace($"Replacing cert '{newAlias}' with the following cert: " + pemCert);
+ client.ReplaceCertificate(newAlias,pemCert);
+
+ _logger.LogTrace($"Setting '{certUsage}' binding to alias '{newAlias}'");
+ client.SetCertUsageBinding(newAlias,certUsageEnum);
+
+ // Perform unused certificate cleanup ---
+ // 1) If a bound alias exists, delete the bound alias
+ HttpResult result;
+ if (oldCertExists)
+ {
+ _logger.LogTrace($"Removing certificate and private key associated with alias '{oldAlias}'");
+ result = client.RemoveCertificate(oldAlias);
- _logger.LogTrace($"Replacing self-signed cert '{reenrollAlias}' with the following cert: " + pemCert);
- client.ReplaceCertificate(reenrollAlias,pemCert);
-
- _logger.LogTrace($"Setting '{certUsage}' binding to alias '{reenrollAlias}'");
- client.SetCertUsageBinding(reenrollAlias, certUsageEnum);
+ if (result.Status == HttpStatus.Warning)
+ {
+ return new JobResult() { Result = OrchestratorJobStatusJobResult.Warning, JobHistoryId = config.JobHistoryId,
+ FailureMessage = $"Reenrollment Job Had Warnings - Refer to logs for more detailed information." };
+ }
+ }
+
+
+ // TESTING build chain functionality
+ /*using var aiaClient = new HttpClient();
+ var builder = new ChainBuilder(aiaClient);
+ var bcX509Cert = new X509CertificateParser().ReadCertificate(x509Cert.RawData);
+ var chain = builder.BuildChain(bcX509Cert, CertificateCollectionOrder.EndEntityFirst);
+
+ int i = 0;
+ foreach (var cert in chain.Certificates)
+ {
+ i++;
+ _logger.LogTrace($"Cert {i}: {cert.SubjectDN.ToString()}");
+ }*/
}
catch (Exception ex)
{
diff --git a/README.md b/README.md
index 4e275e1..53c69fa 100644
--- a/README.md
+++ b/README.md
@@ -247,14 +247,12 @@ the Keyfactor Command Portal
| Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `axis-ipcamera-orchestrator` .NET version to download |
| --------- | ----------- | ----------- | ----------- |
- | Older than `11.0.0` | | | `net6.0` |
- | Between `11.0.0` and `11.5.1` (inclusive) | `net6.0` | | `net6.0` |
- | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` || Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` |
+ | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` |
| `11.6` _and_ newer | `net8.0` | | `net8.0` |
Unzip the archive containing extension assemblies to a known location.
- > **Note** If you don't see an asset with a corresponding .NET version, you should always assume that it was compiled for `net6.0`.
+ > **Note** If you don't see an asset with a corresponding .NET version, you should always assume that it was compiled for `net8.0`.
2. **Locate the Universal Orchestrator extensions directory.**
@@ -435,23 +433,31 @@ There are five (5) possible options:
> [!NOTE]
> A Reenrollment (ODKG) job will not allow enrollment of certificates with **Trust** assigned as the \`Certificate Usage\`.
> Trust CA certificates can be added to the camera via a Management - Add job.
+> These CA certificates establish trust for TLS connections initiated by the camera.
> [!NOTE]
-> For a Reenrollment (ODKG) job, where the \`Certificate Usage\` assigned is **HTTPS**, IP and DNS are added as SANS
-> to the enrolled certificate.
+> As of Keyfactor Command v25.4, SANs can be provided for a Reenrollment (ODKG) job.
+> You must also have installed, at minimum, the Keyfactor Universal Orchestator v25.1
+> in order for the SANs to be sent to the orchestrator.
>
-> IP = Client Machine configured for the certificate store (excluding any port)
+> The Axis IP Camera API *only* supports the addition of DNS and IP SANs. If you add other SAN types to the ODKG job, these will be ignored and not added to the certificate.
>
-> DNS = CN set in the Subject DN
+> * If SANs are NOT provided and the \`Certificate Usage\` assigned is **HTTPS**, IP and DNS will be automatically added as SANs to an enrolled certificate associated with a NEW alias.
+>
+> * IP = Client Machine configured for the certificate store (excluding any port)
+>
+> * DNS = CN set in the Subject DN
## Caveats
> [!NOTE]
-> Reenrollment jobs will not replace or remove a client-server certificate with the same alias. They will also not remove
-> the original certificate if a particular \`Certificate Usage\` had an associated cert. Since the camera has limited storage,
-> it will be up to the user to remove any unused client-server certificates via the AXIS Network Camera GUI.
+> v1.1.0 - A Reenrollment job will now replace the certificate contents for an existing alias on the camera, therefore, not requiring
+> a new alias be supplied for every new certificate enrollment.
+>
+> If a new alias is supplied, a Reenrollment job will not remove the original certificate associated with the \`Certificate Usage\`.
+> Since the camera has limited storage, it will be up to the user to remove any unused certificates via the AXIS Network Camera GUI.
## License
diff --git a/docsource/axisipcamera.md b/docsource/axisipcamera.md
index af3aed2..1971ad6 100644
--- a/docsource/axisipcamera.md
+++ b/docsource/axisipcamera.md
@@ -41,11 +41,17 @@ There are five (5) possible options:
> [!NOTE]
> A Reenrollment (ODKG) job will not allow enrollment of certificates with **Trust** assigned as the \`Certificate Usage\`.
> Trust CA certificates can be added to the camera via a Management - Add job.
+> These CA certificates establish trust for TLS connections initiated by the camera.
> [!NOTE]
-> For a Reenrollment (ODKG) job, where the \`Certificate Usage\` assigned is **HTTPS**, IP and DNS are added as SANS
-> to the enrolled certificate.
+> As of Keyfactor Command v25.4, SANs can be provided for a Reenrollment (ODKG) job.
+> You must also have installed, at minimum, the Keyfactor Universal Orchestator v25.1
+> in order for the SANs to be sent to the orchestrator.
>
-> IP = Client Machine configured for the certificate store (excluding any port)
+> The Axis IP Camera API *only* supports the addition of DNS and IP SANs. If you add other SAN types to the ODKG job, these will be ignored and not added to the certificate.
>
-> DNS = CN set in the Subject DN
\ No newline at end of file
+> * If SANs are NOT provided and the \`Certificate Usage\` assigned is **HTTPS**, IP and DNS will be automatically added as SANs to an enrolled certificate associated with a NEW alias.
+>
+> * IP = Client Machine configured for the certificate store (excluding any port)
+>
+> * DNS = CN set in the Subject DN
\ No newline at end of file
diff --git a/docsource/content.md b/docsource/content.md
index 24e31b2..338b04e 100644
--- a/docsource/content.md
+++ b/docsource/content.md
@@ -70,6 +70,8 @@ These values must match or the session will be denied.
## Caveats
> [!NOTE]
-> Reenrollment jobs will not replace or remove a client-server certificate with the same alias. They will also not remove
-> the original certificate if a particular \`Certificate Usage\` had an associated cert. Since the camera has limited storage,
-> it will be up to the user to remove any unused client-server certificates via the AXIS Network Camera GUI.
+> v1.1.0 - A Reenrollment job will now replace the certificate contents for an existing alias on the camera, therefore, not requiring
+> a new alias be supplied for every new certificate enrollment.
+>
+> If a new alias is supplied, a Reenrollment job will not remove the original certificate associated with the \`Certificate Usage\`.
+> Since the camera has limited storage, it will be up to the user to remove any unused certificates via the AXIS Network Camera GUI.
diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh
new file mode 100755
index 0000000..3ab9e04
--- /dev/null
+++ b/scripts/store_types/bash/curl_create_store_types.sh
@@ -0,0 +1,128 @@
+#!/usr/bin/env bash
+
+# Creates all 1 store types via the Keyfactor Command REST API using curl.
+#
+# Authentication (first matching method is used):
+# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN
+# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET
+# + KEYFACTOR_AUTH_TOKEN_URL
+# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN
+#
+# Always required:
+# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com)
+#
+# Auto-generated by doctool generate-store-type-scripts — do not edit by hand.
+
+if [ -z "${KEYFACTOR_HOSTNAME}" ]; then
+ echo "ERROR: KEYFACTOR_HOSTNAME is required"
+ exit 1
+fi
+
+BASE_URL="https://${KEYFACTOR_HOSTNAME}/keyfactorapi"
+
+# ---------------------------------------------------------------------------
+# Resolve auth
+# ---------------------------------------------------------------------------
+if [ -n "${KEYFACTOR_AUTH_ACCESS_TOKEN}" ]; then
+ BEARER_TOKEN="${KEYFACTOR_AUTH_ACCESS_TOKEN}"
+elif [ -n "${KEYFACTOR_AUTH_CLIENT_ID}" ] && [ -n "${KEYFACTOR_AUTH_CLIENT_SECRET}" ] && [ -n "${KEYFACTOR_AUTH_TOKEN_URL}" ]; then
+ echo "Fetching OAuth token..."
+ BEARER_TOKEN=$(curl -s -X POST "${KEYFACTOR_AUTH_TOKEN_URL}" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ --data-urlencode "grant_type=client_credentials" \
+ --data-urlencode "client_id=${KEYFACTOR_AUTH_CLIENT_ID}" \
+ --data-urlencode "client_secret=${KEYFACTOR_AUTH_CLIENT_SECRET}" | jq -r '.access_token')
+ if [ -z "${BEARER_TOKEN}" ] || [ "${BEARER_TOKEN}" = "null" ]; then
+ echo "ERROR: Failed to fetch OAuth token from ${KEYFACTOR_AUTH_TOKEN_URL}"
+ exit 1
+ fi
+elif [ -n "${KEYFACTOR_USERNAME}" ] && [ -n "${KEYFACTOR_PASSWORD}" ] && [ -n "${KEYFACTOR_DOMAIN}" ]; then
+ BEARER_TOKEN=""
+else
+ echo "ERROR: Authentication required. Set one of:"
+ echo " KEYFACTOR_AUTH_ACCESS_TOKEN"
+ echo " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL"
+ echo " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN"
+ exit 1
+fi
+
+if [ -n "${BEARER_TOKEN}" ]; then
+ CURL_AUTH=("-H" "Authorization: Bearer ${BEARER_TOKEN}")
+else
+ CURL_AUTH=("-u" "${KEYFACTOR_USERNAME}@${KEYFACTOR_DOMAIN}:${KEYFACTOR_PASSWORD}")
+fi
+
+create_store_type() {
+ local name="$1"
+ local body="$2"
+ echo "Creating ${name} store type..."
+ response=$(curl -s -o /dev/null -w "%{http_code}" \
+ -X POST "${BASE_URL}/certificatestoretypes" \
+ -H "Content-Type: application/json" \
+ -H "x-keyfactor-requested-with: APIClient" \
+ "${CURL_AUTH[@]}" \
+ -d "${body}")
+ if [ "$response" = "200" ] || [ "$response" = "201" ]; then
+ echo " OK (HTTP ${response})"
+ else
+ echo " FAILED (HTTP ${response})"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# AxisIPCamera — The IP address of the Camera. Sample is "192.167.231.174:44444". Include the port if necessary.
+# ---------------------------------------------------------------------------
+create_store_type "AxisIPCamera" '{
+ "Name": "Axis IP Camera",
+ "ShortName": "AxisIPCamera",
+ "Capability": "AxisIPCamera",
+ "ServerRequired": true,
+ "BlueprintAllowed": false,
+ "PowerShell": false,
+ "CustomAliasAllowed": "Required",
+ "PrivateKeyAllowed": "Forbidden",
+ "SupportedOperations": {
+ "Add": true,
+ "Create": false,
+ "Discovery": false,
+ "Enrollment": true,
+ "Remove": true
+ },
+ "PasswordOptions": {
+ "EntrySupported": false,
+ "StoreRequired": false,
+ "Style": "Default"
+ },
+ "Properties": [
+ {
+ "Name": "ServerUseSsl",
+ "DisplayName": "Use SSL",
+ "Type": "Bool",
+ "DependsOn": "",
+ "DefaultValue": "true",
+ "Required": true
+ }
+ ],
+ "EntryParameters": [
+ {
+ "Name": "CertUsage",
+ "DisplayName": "Certificate Usage",
+ "Type": "MultipleChoice",
+ "RequiredWhen": {
+ "HasPrivateKey": false,
+ "OnAdd": true,
+ "OnRemove": false,
+ "OnReenrollment": true
+ },
+ "Options": "HTTPS,IEEE802.X,MQTT,Trust,Other",
+ "Description": "The Certificate Usage to assign to the cert after enrollment. Can be left 'Other' to be assigned later."
+ }
+ ],
+ "StorePathDescription": "Enter the Serial Number of the camera e.g. `0b7c3d2f9e8a`",
+ "StorePathType": "",
+ "StorePathValue": "",
+ "JobProperties": []
+}'
+
+
+echo "Completed."
diff --git a/scripts/store_types/bash/kfutil_create_store_types.sh b/scripts/store_types/bash/kfutil_create_store_types.sh
new file mode 100755
index 0000000..27dfa46
--- /dev/null
+++ b/scripts/store_types/bash/kfutil_create_store_types.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+# Creates all 1 store types using kfutil.
+# kfutil reads definitions from the Keyfactor integration catalog.
+#
+# Auth environment variables (first matching method is used):
+# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN
+# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET
+# + KEYFACTOR_AUTH_TOKEN_URL
+# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD
+# + KEYFACTOR_DOMAIN
+#
+# Auto-generated by doctool generate-store-type-scripts — do not edit by hand.
+
+if ! command -v kfutil &> /dev/null; then
+ echo "kfutil could not be found. Please install kfutil"
+ echo "See https://github.com/Keyfactor/kfutil#quickstart"
+ exit 1
+fi
+
+if [ -z "$KEYFACTOR_HOSTNAME" ]; then
+ echo "KEYFACTOR_HOSTNAME not set — launching kfutil login"
+ kfutil login
+fi
+
+kfutil store-types create --name "AxisIPCamera"
+
+echo "Done. All store types created."
diff --git a/scripts/store_types/powershell/kfutil_create_store_types.ps1 b/scripts/store_types/powershell/kfutil_create_store_types.ps1
new file mode 100644
index 0000000..7280dd5
--- /dev/null
+++ b/scripts/store_types/powershell/kfutil_create_store_types.ps1
@@ -0,0 +1,29 @@
+# Creates all 1 store types using kfutil.
+# kfutil reads definitions from the Keyfactor integration catalog.
+#
+# Auth environment variables (first matching method is used):
+# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN
+# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET
+# + KEYFACTOR_AUTH_TOKEN_URL
+# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD
+# + KEYFACTOR_DOMAIN
+#
+# Auto-generated by doctool generate-store-type-scripts — do not edit by hand.
+
+# Uncomment if kfutil is not in your PATH
+# Set-Alias -Name kfutil -Value 'C:\Program Files\Keyfactor\kfutil\kfutil.exe'
+
+if ($null -eq (Get-Command "kfutil" -ErrorAction SilentlyContinue)) {
+ Write-Host "kfutil could not be found. Please install kfutil"
+ Write-Host "See https://github.com/Keyfactor/kfutil#quickstart"
+ exit 1
+}
+
+if (-not $env:KEYFACTOR_HOSTNAME) {
+ Write-Host "KEYFACTOR_HOSTNAME not set — launching kfutil login"
+ & kfutil login
+}
+
+& kfutil store-types create --name "AxisIPCamera"
+
+Write-Host "Done. All store types created."
diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1
new file mode 100644
index 0000000..e23ce6f
--- /dev/null
+++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1
@@ -0,0 +1,122 @@
+# Creates all 1 store types via the Keyfactor Command REST API
+# using PowerShell Invoke-RestMethod.
+#
+# Authentication (first matching method is used):
+# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN
+# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET
+# + KEYFACTOR_AUTH_TOKEN_URL
+# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN
+#
+# Always required:
+# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com)
+#
+# Auto-generated by doctool generate-store-type-scripts — do not edit by hand.
+
+if (-not $env:KEYFACTOR_HOSTNAME) {
+ Write-Error "KEYFACTOR_HOSTNAME is required"
+ exit 1
+}
+
+$uri = "https://$($env:KEYFACTOR_HOSTNAME)/keyfactorapi/certificatestoretypes"
+$headers = @{
+ 'Content-Type' = "application/json"
+ 'x-keyfactor-requested-with' = "APIClient"
+}
+
+# ---------------------------------------------------------------------------
+# Resolve auth
+# ---------------------------------------------------------------------------
+if ($env:KEYFACTOR_AUTH_ACCESS_TOKEN) {
+ $headers['Authorization'] = "Bearer $($env:KEYFACTOR_AUTH_ACCESS_TOKEN)"
+} elseif ($env:KEYFACTOR_AUTH_CLIENT_ID -and $env:KEYFACTOR_AUTH_CLIENT_SECRET -and $env:KEYFACTOR_AUTH_TOKEN_URL) {
+ Write-Host "Fetching OAuth token..."
+ $tokenBody = @{
+ grant_type = 'client_credentials'
+ client_id = $env:KEYFACTOR_AUTH_CLIENT_ID
+ client_secret = $env:KEYFACTOR_AUTH_CLIENT_SECRET
+ }
+ $tokenResp = Invoke-RestMethod -Method Post -Uri $env:KEYFACTOR_AUTH_TOKEN_URL -Body $tokenBody
+ $headers['Authorization'] = "Bearer $($tokenResp.access_token)"
+} elseif ($env:KEYFACTOR_USERNAME -and $env:KEYFACTOR_PASSWORD -and $env:KEYFACTOR_DOMAIN) {
+ $cred = [System.Convert]::ToBase64String(
+ [System.Text.Encoding]::ASCII.GetBytes(
+ "$($env:KEYFACTOR_USERNAME)@$($env:KEYFACTOR_DOMAIN):$($env:KEYFACTOR_PASSWORD)"))
+ $headers['Authorization'] = "Basic $cred"
+} else {
+ Write-Error ("Authentication required. Set one of:`n" +
+ " KEYFACTOR_AUTH_ACCESS_TOKEN`n" +
+ " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL`n" +
+ " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN")
+ exit 1
+}
+
+function New-StoreType {
+ param([string]$Name, [string]$Body)
+ Write-Host "Creating $Name store type..."
+ try {
+ Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $Body -ContentType "application/json" | Out-Null
+ Write-Host " OK"
+ } catch {
+ Write-Warning " FAILED: $($_.Exception.Message)"
+ }
+}
+
+# ---------------------------------------------------------------------------
+# AxisIPCamera — The IP address of the Camera. Sample is "192.167.231.174:44444". Include the port if necessary.
+# ---------------------------------------------------------------------------
+New-StoreType "AxisIPCamera" @'
+{
+ "Name": "Axis IP Camera",
+ "ShortName": "AxisIPCamera",
+ "Capability": "AxisIPCamera",
+ "ServerRequired": true,
+ "BlueprintAllowed": false,
+ "PowerShell": false,
+ "CustomAliasAllowed": "Required",
+ "PrivateKeyAllowed": "Forbidden",
+ "SupportedOperations": {
+ "Add": true,
+ "Create": false,
+ "Discovery": false,
+ "Enrollment": true,
+ "Remove": true
+ },
+ "PasswordOptions": {
+ "EntrySupported": false,
+ "StoreRequired": false,
+ "Style": "Default"
+ },
+ "Properties": [
+ {
+ "Name": "ServerUseSsl",
+ "DisplayName": "Use SSL",
+ "Type": "Bool",
+ "DependsOn": "",
+ "DefaultValue": "true",
+ "Required": true
+ }
+ ],
+ "EntryParameters": [
+ {
+ "Name": "CertUsage",
+ "DisplayName": "Certificate Usage",
+ "Type": "MultipleChoice",
+ "RequiredWhen": {
+ "HasPrivateKey": false,
+ "OnAdd": true,
+ "OnRemove": false,
+ "OnReenrollment": true
+ },
+ "Options": "HTTPS,IEEE802.X,MQTT,Trust,Other",
+ "Description": "The Certificate Usage to assign to the cert after enrollment. Can be left 'Other' to be assigned later."
+ }
+ ],
+ "StorePathDescription": "Enter the Serial Number of the camera e.g. `0b7c3d2f9e8a`",
+ "StorePathType": "",
+ "StorePathValue": "",
+ "JobProperties": []
+}
+'@
+
+
+Write-Host "Completed."