Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions .github/workflows/keyfactor-bootstrap-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,9 @@ on:

jobs:
call-starter-workflow:
uses: keyfactor/actions/.github/workflows/starter.yml@v4
permissions:
contents: write # Explicitly grant write permission
with:
command_token_url: ${{ vars.COMMAND_TOKEN_URL }}
command_hostname: ${{ vars.COMMAND_HOSTNAME }}
command_base_api_path: ${{ vars.COMMAND_API_PATH }}
uses: keyfactor/actions/.github/workflows/starter.yml@v5
secrets:
token: ${{ secrets.V2BUILDTOKEN}}
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }}
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }}
scan_token: ${{ secrets.SAST_TOKEN }}
entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }}
entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }}
command_client_id: ${{ secrets.COMMAND_CLIENT_ID }}
command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }}
token: ${{ secrets.V2BUILDTOKEN}} # REQUIRED
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} # Only required for golang builds
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} # Only required for golang builds
scan_token: ${{ secrets.SAST_TOKEN }} # REQUIRED
75 changes: 40 additions & 35 deletions AxisIPCamera/AxisIPCamera.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,52 @@

<PropertyGroup>
<AppendTargetFrameworkToOutputPath>true</AppendTargetFrameworkToOutputPath>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<ImplicitUsings>disable</ImplicitUsings>
<RootNamespace>Keyfactor.Extensions.Orchestrator.AxisIPCamera</RootNamespace>
<FileVersion>1.1.0</FileVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />
<PackageReference Include="Keyfactor.Logging" Version="1.1.1" />

<None Update="manifest.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<PackageReference Include="Keyfactor.Orchestrators.IOrchestratorJobExtensions" Version="1.0.0" />

<PackageReference Include="RestSharp" Version="112.1.0" />

<None Update="Files\SetHttpsBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetIEEEBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetMQTTBinding.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetHttpsBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetIEEEBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetMQTTBinding.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.1" />

<PackageReference Include="Keyfactor.Logging" Version="1.3.0" />

<PackageReference Include="Keyfactor.Orchestrators.IOrchestratorJobExtensions" Version="1.0.0" />

<PackageReference Include="Keyfactor.PKI" Version="8.3.1" />

<PackageReference Include="RestSharp" Version="112.1.0" />

<None Update="manifest.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetHttpsBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetIEEEBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\SetMQTTBinding.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetHttpsBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetIEEEBinding.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

<None Update="Files\GetMQTTBinding.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
179 changes: 141 additions & 38 deletions AxisIPCamera/Client/AxisHttpClient.cs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,12 +17,12 @@
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using RestSharp;
using RestSharp.Authenticators;

using Keyfactor.Logging;
using Keyfactor.Orchestrators.Extensions;
using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Model;
using Keyfactor.Orchestrators.Extensions.Interfaces;
using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Exceptions;
using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Model;
using Keyfactor.Extensions.Orchestrator.AxisIPCamera.Helpers;

/* AxisHttpClient.cs
Expand Down Expand Up @@ -67,64 +67,118 @@ public AxisHttpClient(JobConfiguration config, CertificateStore store, IPAMSecre
try
{
var errorContext = new CertificateErrorContext();

Logger = LogHandler.GetClassLogger<AxisHttpClient>();
Logger.LogTrace("Entered AxisHttpClient constructor.");
Logger.LogTrace("Initializing Axis IP Camera HTTP client");

// ** NOTE: Ignoring the default config.UseSSL custom field --- we will always connect to the device via HTTPS
var baseRestClientUrl = $"https://{store.ClientMachine}";

Logger.LogDebug($"Base HTTP client URL: {baseRestClientUrl}");

// Initialize custom HTTP handler to validate device identity
RestClientOptions options = null;
Logger.LogTrace($"Adding custom TLS cert validator to the HTTP client options...");
// Retrieve username and password credentials to connect to the device
Logger.LogTrace("Adding device credentials to the HTTP client options...");
string username = PAMUtilities.ResolvePAMField(resolver, Logger, "API Username", config.ServerUsername);
string password = PAMUtilities.ResolvePAMField(resolver, Logger, "API Password", config.ServerPassword);

// See ADR-0001 Axis Camera Authentication Negotiation
// https://keyfactor.atlassian.net/wiki/spaces/IoTStrategy/pages/2968584197/ADR-0001+Axis+Camera+Authentication+Negotiation
//
// The client intentionally uses HttpClientHandler credentials
// rather than RestSharp's HttpBasicAuthenticator to allow
// automatic negotiation of Basic vs Digest authentication
// based on the authentication challenge presented by the camera.
Logger.LogInformation($"Adding custom TLS cert validator to the HTTP client options.");
Logger.LogInformation($"Using HttpClientHandler credential negotiation for camera authentication.");
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
DeviceCertValidator.GetValidator(store.StorePath, errorContext, Logger)
DeviceCertValidator.GetValidator(
store.StorePath,
errorContext,
Logger),

Credentials = new NetworkCredential(username, password),

PreAuthenticate =
false // PreAuthenticate is set to false to avoid the default behavior of sending the username and password in the Authorization header
};
// End ADR-0001

// Initialize HTTP client options with the base URL and custom TLS cert validator
options = new RestClientOptions(baseRestClientUrl)
RestClientOptions options = new RestClientOptions(baseRestClientUrl)
{
ConfigureMessageHandler = _ => handler
};

// Add Basic Auth username and password credentials
Logger.LogTrace("Adding Basic Auth Credentials to the HTTP client options...");
string username = PAMUtilities.ResolvePAMField(resolver, Logger, "API Username", config.ServerUsername);
string password = PAMUtilities.ResolvePAMField(resolver, Logger, "API Password", config.ServerPassword);

options.Authenticator = new HttpBasicAuthenticator(username, password);

// Add SSL validation
Logger.LogTrace("Validating connection to the device...");

_httpClient = new RestClient(options);
var request = new RestRequest("/"); // Initiates the TLS handshake to retrieve the server cert
// Initiates the TLS handshake to verify the ability to authenticate to the camera
var request = new RestRequest("axis-cgi/param.cgi?action=list&group=Network.HTTP.AuthenticationPolicy");
var response = _httpClient.Execute(request);

// Build the list of errors to log to the console
Logger.LogTrace($"Connection to the device response status code: {response.StatusCode}");

// Build the list of SSL certificate errors and log to the console
StringBuilder errorSb = new StringBuilder();
if (errorContext.HasErrors)
{
foreach (var error in errorContext.Errors)
{
errorSb.AppendLine(error);
}
throw new Exception(errorSb.ToString());

throw new DeviceCertValidationException(
$"Device TLS cert validator errors encountered --- {errorSb}");
}

Logger.LogTrace($"Connection to the device response status code: {response.StatusCode}");

// Begin ADR-0001 Axis Camera Authentication Negotiation
// Log the WWW-Authenticate headers if 401 Unauthorized returned
// Throw exception if connection cannot be made successfully to the camera
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
Logger.LogWarning("Camera returned 401 Unauthorized");

foreach (var header in response.Headers)
{
Logger.LogDebug($"WWW-Authenticate header: {header.Value}");
}

throw new AuthenticationException(
"Authentication to the Axis camera failed due to 401 Unauthorized. Verify the configured credentials.");
}

if (!response.IsSuccessful)
{
throw new Exception(response.ErrorMessage);
}
// End ADR-0001

Logger.LogTrace("Completed Initialization of Axis IP Camera HTTP Client");
Logger.LogTrace("Leaving AxisHttpClient constructor.");
}
catch (Exception e)
catch (DeviceCertValidationException ex)
{
Logger.LogError("Device TLS cert validation failed while connecting to the device: " + LogHandler.FlattenException(ex));
throw new Exception(ex.Message);
}
catch (AuthenticationException ex1)
{
Logger.LogError("Error initializing Axis IP Camera HTTP Client: " + LogHandler.FlattenException(e));
throw new Exception($"Device identity could not be verified successfully --- {e.Message}");
Logger.LogError("Authentication to the device failed: " + LogHandler.FlattenException(ex1));
throw new Exception(ex1.Message);
}
catch (HttpRequestException ex2)
{
Logger.LogError("Failed to communicate with the device: " + LogHandler.FlattenException(ex2));
throw new Exception(ex2.Message);
}
catch (Exception ex3)
{
Logger.LogError("Unexpected error while connecting to the device: " + LogHandler.FlattenException(ex3));
throw new Exception(ex3.Message);
}
}

Expand Down Expand Up @@ -524,30 +578,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<RestApiResponse>(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<ErrorData>(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);
}
}

/// <summary>
/// Removes a certificate with private key from the device.
/// </summary>
/// <param name="alias">Unique identifier of the CA certificate to be removed</param>
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<RestApiResponse>(httpResponse.Content);
if (apiResponse.Status == Constants.Status.Success)
{
Logger.MethodExit();
}
else
if (apiResponse.Status != Constants.Status.Success)
{
ErrorData error = JsonConvert.DeserializeObject<ErrorData>(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);
}
}
Expand Down
26 changes: 26 additions & 0 deletions AxisIPCamera/Exceptions/AuthenticationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 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;

namespace Keyfactor.Extensions.Orchestrator.AxisIPCamera.Exceptions
{
public class AuthenticationException : Exception
{
public AuthenticationException(string message)
: base(message)
{
}

public AuthenticationException(
string message,
Exception innerException)
: base(message, innerException)
{
}
}
}
Loading