using Microsoft.Azure.Management.Dns;
using Microsoft.Azure.Management.Dns.Models;
using Microsoft.Azure.Services.AppAuthentication;
using Microsoft.Rest;
using Microsoft.Rest.Azure.Authentication;
using PKISharp.WACS.Clients.DNS;
using PKISharp.WACS.Plugins.Interfaces;
using PKISharp.WACS.Services;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace PKISharp.WACS.Plugins.ValidationPlugins.Dns
{
///
/// Handle creation of DNS records in Azure
///
internal class Azure : DnsValidation
{
private DnsManagementClient _azureDnsClient;
private readonly Uri _resourceManagerEndpoint;
private readonly ProxyService _proxyService;
private readonly AzureOptions _options;
private readonly Dictionary> _recordSets;
private IEnumerable _hostedZones;
public Azure(AzureOptions options,
LookupClientProvider dnsClient,
ProxyService proxyService,
ILogService log,
ISettingsService settings) : base(dnsClient, log, settings)
{
_options = options;
_proxyService = proxyService;
_recordSets = new Dictionary>();
_resourceManagerEndpoint = new Uri(AzureEnvironments.ResourceManagerUrls[AzureEnvironments.AzureCloud]);
if (!string.IsNullOrEmpty(options.AzureEnvironment))
{
if (!AzureEnvironments.ResourceManagerUrls.TryGetValue(options.AzureEnvironment, out var endpoint))
{
// Custom endpoint
endpoint = options.AzureEnvironment;
}
try
{
_resourceManagerEndpoint = new Uri(endpoint);
}
catch (Exception ex)
{
_log.Error(ex, "Could not parse Azure endpoint url. Falling back to default.");
}
}
}
///
/// Allow this plugin to process multiple validations at the same time.
/// They will still be prepared and cleaned in serial order though not
/// to overwhelm the DnsManagementClient or risk threads overwriting
/// eachothers changes.
///
public override ParallelOperations Parallelism => ParallelOperations.Answer;
///
/// Create record in Azure DNS
///
///
///
///
///
public override async Task CreateRecord(DnsValidationRecord record)
{
var zone = await GetHostedZone(record.Authority.Domain);
if (zone == null)
{
return false;
}
// Create or update record set parameters
var txtRecord = new TxtRecord(new[] { record.Value });
if (!_recordSets.ContainsKey(zone))
{
_recordSets.Add(zone, new Dictionary());
}
var zoneRecords = _recordSets[zone];
var relativeKey = RelativeRecordName(zone, record.Authority.Domain);
if (!zoneRecords.ContainsKey(relativeKey))
{
zoneRecords.Add(
relativeKey,
new RecordSet
{
TTL = 0,
TxtRecords = new List { txtRecord }
});
}
else
{
zoneRecords[relativeKey].TxtRecords.Add(txtRecord);
}
return true;
}
///
/// Send all buffered changes to Azure
///
///
public override async Task SaveChanges()
{
var updateTasks = new List();
foreach (var zone in _recordSets.Keys)
{
foreach (var domain in _recordSets[zone].Keys)
{
updateTasks.Add(CreateOrUpdateRecordSet(zone, domain));
}
}
await Task.WhenAll(updateTasks);
}
///
/// Store a single recordset
///
///
///
///
///
private async Task CreateOrUpdateRecordSet(string zone, string domain)
{
try
{
var newSet = _recordSets[zone][domain];
var client = await GetClient();
try
{
var originalSet = await client.RecordSets.GetAsync(_options.ResourceGroupName,
zone,
domain,
RecordType.TXT);
_recordSets[zone][domain] = originalSet;
}
catch
{
_recordSets[zone][domain] = null;
}
if (newSet == null)
{
await client.RecordSets.DeleteAsync(
_options.ResourceGroupName,
zone,
domain,
RecordType.TXT);
}
else
{
_ = await client.RecordSets.CreateOrUpdateAsync(
_options.ResourceGroupName,
zone,
domain,
RecordType.TXT,
newSet);
}
}
catch (Exception ex)
{
_log.Error(ex, "Error updating DNS records in {zone} ({domain})", zone, domain);
}
}
private async Task GetClient()
{
if (_azureDnsClient == null)
{
// Build the service credentials and DNS management client
ServiceClientCredentials credentials;
// Decide between Managed Service Identity (MSI)
// and service principal with client credentials
if (_options.UseMsi)
{
var azureServiceTokenProvider = new AzureServiceTokenProvider();
var accessToken = await azureServiceTokenProvider.GetAccessTokenAsync(_resourceManagerEndpoint.ToString());
credentials = new TokenCredentials(accessToken);
}
else
{
credentials = await ApplicationTokenProvider.LoginSilentAsync(
_options.TenantId,
_options.ClientId,
_options.Secret.Value,
GetActiveDirectorySettingsForAzureEnvironment());
}
_azureDnsClient = new DnsManagementClient(credentials, _proxyService.GetHttpClient(), true)
{
BaseUri = _resourceManagerEndpoint,
SubscriptionId = _options.SubscriptionId
};
}
return _azureDnsClient;
}
///
/// Retrieve active directory settings based on the current Azure environment
///
///
private ActiveDirectoryServiceSettings GetActiveDirectorySettingsForAzureEnvironment()
{
return _options.AzureEnvironment switch
{
AzureEnvironments.AzureChinaCloud => ActiveDirectoryServiceSettings.AzureChina,
AzureEnvironments.AzureUSGovernment => ActiveDirectoryServiceSettings.AzureUSGovernment,
AzureEnvironments.AzureGermanCloud => ActiveDirectoryServiceSettings.AzureGermany,
_ => ActiveDirectoryServiceSettings.Azure,
};
}
///
/// Translate full host name to zone relative name
///
///
///
///
private string RelativeRecordName(string zone, string recordName)
{
var ret = recordName.Substring(0, recordName.LastIndexOf(zone)).TrimEnd('.');
return string.IsNullOrEmpty(ret) ? "@" : ret;
}
///
/// Find the approriate hosting zone to use for record updates
///
///
///
private async Task GetHostedZone(string recordName)
{
// Cache so we don't have to repeat this more than once for each renewal
if (_hostedZones == null)
{
var client = await GetClient();
var zones = new List();
var response = await client.Zones.ListByResourceGroupAsync(_options.ResourceGroupName);
zones.AddRange(response);
while (!string.IsNullOrEmpty(response.NextPageLink))
{
response = await client.Zones.ListByResourceGroupNextAsync(response.NextPageLink);
}
_log.Debug("Found {count} hosted zones in Azure Resource Group {rg}", zones.Count, _options.ResourceGroupName);
_hostedZones = zones;
}
var hostedZone = FindBestMatch(_hostedZones.ToDictionary(x => x.Name), recordName);
if (hostedZone != null)
{
return hostedZone.Name;
}
_log.Error(
"Can't find hosted zone for {recordName} in resource group {ResourceGroupName}",
recordName,
_options.ResourceGroupName);
return null;
}
///
/// Ignored because we keep track of our list of changes
///
///
///
public override Task DeleteRecord(DnsValidationRecord record) => Task.CompletedTask;
///
/// Clear created createds
///
///
public override async Task Finalize() =>
// We save the original record sets, so this should restore them
await SaveChanges();
}
}