Feature/876 certificate rotation cli (#909)
* Provide certificate update mechanism * Implement certificate revocation command * Make RotateCertificateOptions immutable * Revert obsolete code * Update Tools/Cli-LoRa-Device-Provisioning/CommandLineOptions/RevokeOptions.cs Co-authored-by: Daniele Antonio Maggio <damaggio@microsoft.com> * Fix bug in region validation * Fix no-cups path * Log if thumbprint not found Co-authored-by: Daniele Antonio Maggio <damaggio@microsoft.com>
This commit is contained in:
Родитель
29e20febee
Коммит
3a02b39f56
|
@ -35,7 +35,7 @@
|
|||
<PackageReference Include="Azure.Storage.Blobs" Version="12.10.0" />
|
||||
<PackageReference Include="CommandLineParser" Version="2.8.0" />
|
||||
<PackageReference Include="Crc32.NET" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Devices" Version="1.22.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Devices" Version="1.36.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
|
||||
namespace LoRaWan.Tools.CLI.CommandLineOptions
|
||||
{
|
||||
using CommandLine;
|
||||
|
||||
[Verb("revoke", HelpText = "Revokes a client certificate thumbprint for a station.")]
|
||||
public class RevokeOptions
|
||||
{
|
||||
public RevokeOptions(string stationEui, string clientCertificateThumbprint)
|
||||
{
|
||||
StationEui = stationEui;
|
||||
ClientCertificateThumbprint = clientCertificateThumbprint;
|
||||
}
|
||||
|
||||
[Option("stationeui",
|
||||
Required = true,
|
||||
HelpText = "Station EUI: Required id '--concentrator' switch is set. A 16 bit hex string ('AABBCCDDEEFFGGHH').")]
|
||||
public string StationEui { get; }
|
||||
|
||||
[Option("client-certificate-thumbprint",
|
||||
Required = true,
|
||||
HelpText = "Client certificate thumbprint: A client certificate thumbprint that should be revoked and not accepted anymore by the CUPS/LNS endpoints.")]
|
||||
public string ClientCertificateThumbprint { get; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
// Copyright (c) Microsoft. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
|
||||
namespace LoRaWan.Tools.CLI.CommandLineOptions
|
||||
{
|
||||
using CommandLine;
|
||||
|
||||
[Verb("rotate-certificate", HelpText = "Rotates the certificates for a station.")]
|
||||
public class RotateCertificateOptions
|
||||
{
|
||||
public RotateCertificateOptions(string stationEui, string certificateBundleLocation, string clientCertificateThumbprint)
|
||||
{
|
||||
StationEui = stationEui;
|
||||
CertificateBundleLocation = certificateBundleLocation;
|
||||
ClientCertificateThumbprint = clientCertificateThumbprint;
|
||||
}
|
||||
|
||||
[Option("stationeui",
|
||||
Required = true,
|
||||
HelpText = "Station EUI: Required id '--concentrator' switch is set. A 16 bit hex string ('AABBCCDDEEFFGGHH').")]
|
||||
public string StationEui { get; }
|
||||
|
||||
[Option("certificate-bundle-location",
|
||||
Required = true,
|
||||
HelpText = "Certificate bundle location: Points to the location of the (UTF-8-encoded) certificate bundle file.")]
|
||||
public string CertificateBundleLocation { get; }
|
||||
|
||||
[Option("client-certificate-thumbprint",
|
||||
Required = true,
|
||||
HelpText = "Client certificate thumbprint: A client certificate thumbprint that should be accepted by the CUPS/LNS endpoints.")]
|
||||
public string ClientCertificateThumbprint { get; }
|
||||
}
|
||||
}
|
|
@ -7,9 +7,7 @@ namespace LoRaWan.Tools.CLI.Helpers
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Storage.Blobs;
|
||||
using LoRaWan.Tools.CLI.Options;
|
||||
using Microsoft.Azure.Devices;
|
||||
using Microsoft.Azure.Devices.Shared;
|
||||
|
@ -909,8 +907,12 @@ namespace LoRaWan.Tools.CLI.Helpers
|
|||
var isValid = true;
|
||||
TrackErrorIf(string.IsNullOrEmpty(opts.StationEui), "'Concentrator' device type has been specified but StationEui option is missing.");
|
||||
TrackErrorIf(string.IsNullOrEmpty(opts.Region), "'Concentrator' device type has been specified but Region option is missing.");
|
||||
TrackErrorIf(opts.Region is { } region && !File.Exists(Path.Combine(DefaultRouterConfigFolder, $"{region.ToUpperInvariant()}.json")),
|
||||
$"'Concentrator' device type has been specified with Region '{opts.Region.ToUpperInvariant()}' but no default router config file was found.");
|
||||
if (opts.Region is { } region)
|
||||
{
|
||||
TrackErrorIf(!File.Exists(Path.Combine(DefaultRouterConfigFolder, $"{region.ToUpperInvariant()}.json")),
|
||||
$"'Concentrator' device type has been specified with Region '{region.ToUpperInvariant()}' but no default router config file was found.");
|
||||
}
|
||||
|
||||
if (opts.NoCups)
|
||||
{
|
||||
TrackErrorIf(opts.TcUri is not null, "TC URI must not be defined if --no-cups is set.");
|
||||
|
@ -952,19 +954,22 @@ namespace LoRaWan.Tools.CLI.Helpers
|
|||
var propObject = JsonConvert.DeserializeObject<JObject>(jsonString);
|
||||
twinProperties.Desired[TwinProperty.RouterConfig] = propObject;
|
||||
|
||||
// Add CUPS configuration
|
||||
twinProperties.Desired[TwinProperty.Cups] = new JObject
|
||||
if (!opts.NoCups)
|
||||
{
|
||||
[TwinProperty.TcCredentialUrl] = certificateBundleLocation,
|
||||
[TwinProperty.TcCredentialCrc] = crcChecksum,
|
||||
[TwinProperty.CupsCredentialUrl] = certificateBundleLocation,
|
||||
[TwinProperty.CupsCredentialCrc] = crcChecksum,
|
||||
[TwinProperty.CupsUri] = opts.CupsUri,
|
||||
[TwinProperty.TcUri] = opts.TcUri,
|
||||
};
|
||||
// Add CUPS configuration
|
||||
twinProperties.Desired[TwinProperty.Cups] = new JObject
|
||||
{
|
||||
[TwinProperty.TcCredentialUrl] = certificateBundleLocation,
|
||||
[TwinProperty.TcCredentialCrc] = crcChecksum,
|
||||
[TwinProperty.CupsCredentialUrl] = certificateBundleLocation,
|
||||
[TwinProperty.CupsCredentialCrc] = crcChecksum,
|
||||
[TwinProperty.CupsUri] = opts.CupsUri,
|
||||
[TwinProperty.TcUri] = opts.TcUri,
|
||||
};
|
||||
|
||||
// Add client certificate thumbprints
|
||||
twinProperties.Desired[TwinProperty.ClientThumbprint] = new JArray(opts.ClientCertificateThumbprints);
|
||||
// Add client certificate thumbprints
|
||||
twinProperties.Desired[TwinProperty.ClientThumbprint] = new JArray(opts.ClientCertificateThumbprints);
|
||||
}
|
||||
|
||||
return new Twin
|
||||
{
|
||||
|
|
|
@ -6,15 +6,18 @@ namespace LoRaWan.Tools.CLI
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using CommandLine;
|
||||
using LoRaWan.Tools.CLI.CommandLineOptions;
|
||||
using LoRaWan.Tools.CLI.Helpers;
|
||||
using LoRaWan.Tools.CLI.Options;
|
||||
using Microsoft.Azure.Devices.Shared;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
public class Program
|
||||
{
|
||||
|
@ -31,7 +34,7 @@ namespace LoRaWan.Tools.CLI
|
|||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
var success = await Parser.Default.ParseArguments<ListOptions, QueryOptions, VerifyOptions, BulkVerifyOptions, AddOptions, UpdateOptions, RemoveOptions>(args)
|
||||
var success = await Parser.Default.ParseArguments<ListOptions, QueryOptions, VerifyOptions, BulkVerifyOptions, AddOptions, UpdateOptions, RemoveOptions, RotateCertificateOptions, RevokeOptions>(args)
|
||||
.MapResult(
|
||||
(ListOptions opts) => RunListAndReturnExitCode(opts),
|
||||
(QueryOptions opts) => RunQueryAndReturnExitCode(opts),
|
||||
|
@ -40,6 +43,8 @@ namespace LoRaWan.Tools.CLI
|
|||
(AddOptions opts) => RunAddAndReturnExitCode(opts),
|
||||
(UpdateOptions opts) => RunUpdateAndReturnExitCode(opts),
|
||||
(RemoveOptions opts) => RunRemoveAndReturnExitCode(opts),
|
||||
(RotateCertificateOptions opts) => RunRotateCertificateAndReturnExitCodeAsync(opts),
|
||||
(RevokeOptions opts) => RunRevokeAndReturnExitCodeAsync(opts),
|
||||
errs => Task.FromResult(false));
|
||||
|
||||
if (success)
|
||||
|
@ -163,43 +168,25 @@ namespace LoRaWan.Tools.CLI
|
|||
return false;
|
||||
}
|
||||
|
||||
// renormalize to Unix line endings
|
||||
var certificateBundleBlobName = opts.StationEui;
|
||||
var blobClient = configurationHelper.CertificateStorageContainerClient.GetBlobClient(certificateBundleBlobName);
|
||||
var certificateContent = Regex.Replace(File.ReadAllText(opts.CertificateBundleLocation), @"\r\n|\n\r|\n|\r", "\n");
|
||||
|
||||
try
|
||||
if (await iotDeviceHelper.QueryDeviceTwin(opts.StationEui, configurationHelper) is not null)
|
||||
{
|
||||
_ = await blobClient.UploadAsync(new BinaryData(Encoding.UTF8.GetBytes(certificateContent)), overwrite: false);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobAlreadyExists)
|
||||
{
|
||||
StatusConsole.WriteLogLine(MessageType.Error, $"Uploading certificate bundle failed because bundle already exists. Please use the 'update' verb to update existing concentrator configuration.");
|
||||
return false;
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
StatusConsole.WriteLogLine(MessageType.Error, $"Uploading certificate bundle failed with error: '{ex.Message}'.");
|
||||
StatusConsole.WriteLogLine(MessageType.Error, "Station was already created, please use the 'update' verb to update an existing station.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
if (opts.NoCups)
|
||||
{
|
||||
var crc = new Force.Crc32.Crc32Algorithm();
|
||||
var crcHash = BinaryPrimitives.ReadUInt32BigEndian(crc.ComputeHash(Encoding.UTF8.GetBytes(certificateContent)));
|
||||
var twin = iotDeviceHelper.CreateConcentratorTwin(opts, crcHash, blobClient.Uri);
|
||||
var success = await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, true);
|
||||
if (!success) await CleanupAsync();
|
||||
return success;
|
||||
var twin = iotDeviceHelper.CreateConcentratorTwin(opts, 0, null);
|
||||
return await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, true);
|
||||
}
|
||||
catch (Exception)
|
||||
else
|
||||
{
|
||||
// If the twin was not successfully created, remove the uploaded certificate bundle.
|
||||
await CleanupAsync();
|
||||
throw;
|
||||
return await UploadCertificateBundleAsync(opts.CertificateBundleLocation, opts.StationEui, async (crcHash, bundleStorageUri) =>
|
||||
{
|
||||
var twin = iotDeviceHelper.CreateConcentratorTwin(opts, crcHash, bundleStorageUri);
|
||||
return await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, true);
|
||||
});
|
||||
}
|
||||
|
||||
Task CleanupAsync() => blobClient.DeleteIfExistsAsync();
|
||||
}
|
||||
|
||||
opts = iotDeviceHelper.CompleteMissingAddOptions(opts, configurationHelper);
|
||||
|
@ -223,6 +210,84 @@ namespace LoRaWan.Tools.CLI
|
|||
return isSuccess;
|
||||
}
|
||||
|
||||
private static async Task<bool> RunRotateCertificateAndReturnExitCodeAsync(RotateCertificateOptions opts)
|
||||
{
|
||||
if (!configurationHelper.ReadConfig())
|
||||
return false;
|
||||
|
||||
if (!File.Exists(opts.CertificateBundleLocation))
|
||||
{
|
||||
StatusConsole.WriteLogLine(MessageType.Error, "Certificate bundle does not exist at defined location.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var twin = await iotDeviceHelper.QueryDeviceTwin(opts.StationEui, configurationHelper);
|
||||
|
||||
if (twin is null)
|
||||
{
|
||||
StatusConsole.WriteLogLine(MessageType.Error, "Device was not found in IoT Hub. Please create it first.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var twinJObject = JsonConvert.DeserializeObject<JObject>(twin.Properties.Desired.ToJson());
|
||||
var cupsProperties = twinJObject[TwinProperty.Cups];
|
||||
var oldCupsCredentialBundleLocation = new Uri(cupsProperties[TwinProperty.CupsCredentialUrl].ToString());
|
||||
var oldTcCredentialBundleLocation = new Uri(cupsProperties[TwinProperty.TcCredentialUrl].ToString());
|
||||
|
||||
// Upload new certificate bundle
|
||||
var success = await UploadCertificateBundleAsync(opts.CertificateBundleLocation, opts.StationEui, async (crcHash, bundleStorageUri) =>
|
||||
{
|
||||
var thumbprints = (JArray)twinJObject[TwinProperty.ClientThumbprint];
|
||||
if (!thumbprints.Any(t => string.Equals(t.ToString(), opts.ClientCertificateThumbprint, StringComparison.OrdinalIgnoreCase)))
|
||||
thumbprints.Add(JToken.Parse($"\"{opts.ClientCertificateThumbprint}\""));
|
||||
|
||||
twin.Properties.Desired[TwinProperty.ClientThumbprint] = thumbprints;
|
||||
twin.Properties.Desired[TwinProperty.Cups][TwinProperty.CupsCredentialCrc] = crcHash;
|
||||
twin.Properties.Desired[TwinProperty.Cups][TwinProperty.TcCredentialCrc] = crcHash;
|
||||
twin.Properties.Desired[TwinProperty.Cups][TwinProperty.CupsCredentialUrl] = bundleStorageUri;
|
||||
twin.Properties.Desired[TwinProperty.Cups][TwinProperty.TcCredentialUrl] = bundleStorageUri;
|
||||
|
||||
return await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, isNewDevice: false);
|
||||
});
|
||||
|
||||
// Clean up old certificate bundles
|
||||
try
|
||||
{
|
||||
_ = await configurationHelper.CertificateStorageContainerClient.DeleteBlobIfExistsAsync(oldCupsCredentialBundleLocation.Segments.Last());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = await configurationHelper.CertificateStorageContainerClient.DeleteBlobIfExistsAsync(oldTcCredentialBundleLocation.Segments.Last());
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private static async Task<bool> RunRevokeAndReturnExitCodeAsync(RevokeOptions opts)
|
||||
{
|
||||
if (!configurationHelper.ReadConfig())
|
||||
return false;
|
||||
|
||||
var twin = await iotDeviceHelper.QueryDeviceTwin(opts.StationEui, configurationHelper);
|
||||
|
||||
if (twin is null)
|
||||
{
|
||||
StatusConsole.WriteLogLine(MessageType.Error, "Device was not found in IoT Hub. Please create it first.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var twinJObject = JsonConvert.DeserializeObject<JObject>(twin.Properties.Desired.ToJson());
|
||||
var clientThumprints = twinJObject[TwinProperty.ClientThumbprint];
|
||||
var t = clientThumprints.FirstOrDefault(t => t.ToString().Equals(opts.ClientCertificateThumbprint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (t is null)
|
||||
StatusConsole.WriteLogLine(MessageType.Error, "Specified thumbprint not found.");
|
||||
|
||||
t?.Remove();
|
||||
twin.Properties.Desired[TwinProperty.ClientThumbprint] = clientThumprints;
|
||||
return await iotDeviceHelper.WriteDeviceTwin(twin, opts.StationEui, configurationHelper, isNewDevice: false);
|
||||
}
|
||||
|
||||
private static async Task<bool> RunUpdateAndReturnExitCode(UpdateOptions opts)
|
||||
{
|
||||
if (!configurationHelper.ReadConfig())
|
||||
|
@ -270,6 +335,40 @@ namespace LoRaWan.Tools.CLI
|
|||
return isSuccess;
|
||||
}
|
||||
|
||||
private static async Task<bool> UploadCertificateBundleAsync(string certificateBundleLocation, string stationEui, Func<uint, Uri, Task<bool>> uploadSuccessActionAsync)
|
||||
{
|
||||
var certificateBundleBlobName = $"{stationEui}-{Guid.NewGuid():N}";
|
||||
var blobClient = configurationHelper.CertificateStorageContainerClient.GetBlobClient(certificateBundleBlobName);
|
||||
var fileContent = await File.ReadAllTextAsync(certificateBundleLocation);
|
||||
var certificateContent = Regex.Replace(fileContent, @"\r\n|\n\r|\n|\r", "\n");
|
||||
|
||||
try
|
||||
{
|
||||
_ = await blobClient.UploadAsync(new BinaryData(Encoding.UTF8.GetBytes(certificateContent)), overwrite: false);
|
||||
}
|
||||
catch (RequestFailedException ex)
|
||||
{
|
||||
StatusConsole.WriteLogLine(MessageType.Error, $"Uploading certificate bundle failed with error: '{ex.Message}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var crc = new Force.Crc32.Crc32Algorithm();
|
||||
var crcHash = BinaryPrimitives.ReadUInt32BigEndian(crc.ComputeHash(Encoding.UTF8.GetBytes(certificateContent)));
|
||||
if (!await uploadSuccessActionAsync(crcHash, blobClient.Uri))
|
||||
await CleanupAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await CleanupAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
Task CleanupAsync() => blobClient.DeleteIfExistsAsync();
|
||||
}
|
||||
|
||||
private static async Task<bool> RunRemoveAndReturnExitCode(RemoveOptions opts)
|
||||
{
|
||||
if (!configurationHelper.ReadConfig())
|
||||
|
|
Загрузка…
Ссылка в новой задаче