333 lines
13 KiB
C#
333 lines
13 KiB
C#
using System.Collections.Immutable;
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Vexer.Connectors.Cisco.CSAF.Configuration;
|
|
using StellaOps.Vexer.Core;
|
|
using System.IO.Abstractions;
|
|
|
|
namespace StellaOps.Vexer.Connectors.Cisco.CSAF.Metadata;
|
|
|
|
public sealed class CiscoProviderMetadataLoader
|
|
{
|
|
public const string CacheKey = "StellaOps.Vexer.Connectors.Cisco.CSAF.Metadata";
|
|
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly IMemoryCache _memoryCache;
|
|
private readonly ILogger<CiscoProviderMetadataLoader> _logger;
|
|
private readonly CiscoConnectorOptions _options;
|
|
private readonly IFileSystem _fileSystem;
|
|
private readonly JsonSerializerOptions _serializerOptions;
|
|
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
|
|
|
public CiscoProviderMetadataLoader(
|
|
IHttpClientFactory httpClientFactory,
|
|
IMemoryCache memoryCache,
|
|
IOptions<CiscoConnectorOptions> options,
|
|
ILogger<CiscoProviderMetadataLoader> logger,
|
|
IFileSystem? fileSystem = null)
|
|
{
|
|
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
|
_memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
|
_fileSystem = fileSystem ?? new FileSystem();
|
|
_serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
};
|
|
}
|
|
|
|
public async Task<CiscoProviderMetadataResult> LoadAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out var cached) && cached is not null && !cached.IsExpired())
|
|
{
|
|
_logger.LogDebug("Returning cached Cisco provider metadata (expires {Expires}).", cached.ExpiresAt);
|
|
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
|
|
}
|
|
|
|
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out cached) && cached is not null && !cached.IsExpired())
|
|
{
|
|
return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
|
|
}
|
|
|
|
CacheEntry? previous = cached;
|
|
|
|
if (!_options.PreferOfflineSnapshot)
|
|
{
|
|
var network = await TryFetchFromNetworkAsync(previous, cancellationToken).ConfigureAwait(false);
|
|
if (network is not null)
|
|
{
|
|
StoreCache(network);
|
|
return new CiscoProviderMetadataResult(network.Provider, network.FetchedAt, false, false);
|
|
}
|
|
}
|
|
|
|
var offline = TryLoadFromOffline();
|
|
if (offline is not null)
|
|
{
|
|
var entry = offline with
|
|
{
|
|
FetchedAt = DateTimeOffset.UtcNow,
|
|
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
|
|
FromOffline = true,
|
|
};
|
|
StoreCache(entry);
|
|
return new CiscoProviderMetadataResult(entry.Provider, entry.FetchedAt, true, false);
|
|
}
|
|
|
|
throw new InvalidOperationException("Unable to load Cisco CSAF provider metadata from network or offline snapshot.");
|
|
}
|
|
finally
|
|
{
|
|
_semaphore.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<CacheEntry?> TryFetchFromNetworkAsync(CacheEntry? previous, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var client = _httpClientFactory.CreateClient(CiscoConnectorOptions.HttpClientName);
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, _options.MetadataUri);
|
|
if (!string.IsNullOrWhiteSpace(_options.ApiToken))
|
|
{
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiToken);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(previous?.ETag) && EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
|
|
{
|
|
request.Headers.IfNoneMatch.Add(etag);
|
|
}
|
|
|
|
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
|
|
{
|
|
_logger.LogDebug("Cisco provider metadata not modified (etag {ETag}).", previous.ETag);
|
|
return previous with
|
|
{
|
|
FetchedAt = DateTimeOffset.UtcNow,
|
|
ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
|
|
};
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
var provider = ParseProvider(payload);
|
|
var etagHeader = response.Headers.ETag?.ToString();
|
|
|
|
if (_options.PersistOfflineSnapshot && !string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
|
|
{
|
|
try
|
|
{
|
|
_fileSystem.File.WriteAllText(_options.OfflineSnapshotPath, payload);
|
|
_logger.LogDebug("Persisted Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to persist Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
|
|
}
|
|
}
|
|
|
|
return new CacheEntry(
|
|
provider,
|
|
DateTimeOffset.UtcNow,
|
|
DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
|
|
etagHeader,
|
|
FromOffline: false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException && !_options.PreferOfflineSnapshot)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to fetch Cisco provider metadata from {Uri}; falling back to offline snapshot when available.", _options.MetadataUri);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private CacheEntry? TryLoadFromOffline()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!_fileSystem.File.Exists(_options.OfflineSnapshotPath))
|
|
{
|
|
_logger.LogWarning("Cisco offline snapshot path {Path} does not exist.", _options.OfflineSnapshotPath);
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
var payload = _fileSystem.File.ReadAllText(_options.OfflineSnapshotPath);
|
|
var provider = ParseProvider(payload);
|
|
return new CacheEntry(provider, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow + _options.MetadataCacheDuration, null, true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to load Cisco provider metadata from offline snapshot {Path}.", _options.OfflineSnapshotPath);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private VexProvider ParseProvider(string payload)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(payload))
|
|
{
|
|
throw new InvalidOperationException("Cisco provider metadata payload was empty.");
|
|
}
|
|
|
|
ProviderMetadataDocument? document;
|
|
try
|
|
{
|
|
document = JsonSerializer.Deserialize<ProviderMetadataDocument>(payload, _serializerOptions);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
throw new InvalidOperationException("Failed to parse Cisco provider metadata.", ex);
|
|
}
|
|
|
|
if (document?.Metadata?.Publisher?.ContactDetails is null || string.IsNullOrWhiteSpace(document.Metadata.Publisher.ContactDetails.Id))
|
|
{
|
|
throw new InvalidOperationException("Cisco provider metadata did not include a publisher identifier.");
|
|
}
|
|
|
|
var discovery = new VexProviderDiscovery(document.Discovery?.WellKnown, document.Discovery?.RolIe);
|
|
var trust = document.Trust is null
|
|
? VexProviderTrust.Default
|
|
: new VexProviderTrust(
|
|
document.Trust.Weight ?? 1.0,
|
|
document.Trust.Cosign is null ? null : new VexCosignTrust(document.Trust.Cosign.Issuer ?? string.Empty, document.Trust.Cosign.IdentityPattern ?? string.Empty),
|
|
document.Trust.PgpFingerprints ?? Enumerable.Empty<string>());
|
|
|
|
var directories = document.Distributions?.Directories is null
|
|
? Enumerable.Empty<Uri>()
|
|
: document.Distributions.Directories
|
|
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
|
.Select(static s => Uri.TryCreate(s, UriKind.Absolute, out var uri) ? uri : null)
|
|
.Where(static uri => uri is not null)!
|
|
.Select(static uri => uri!);
|
|
|
|
return new VexProvider(
|
|
id: document.Metadata.Publisher.ContactDetails.Id,
|
|
displayName: document.Metadata.Publisher.Name ?? document.Metadata.Publisher.ContactDetails.Id,
|
|
kind: document.Metadata.Publisher.Category?.Equals("vendor", StringComparison.OrdinalIgnoreCase) == true ? VexProviderKind.Vendor : VexProviderKind.Hub,
|
|
baseUris: directories,
|
|
discovery: discovery,
|
|
trust: trust,
|
|
enabled: true);
|
|
}
|
|
|
|
private void StoreCache(CacheEntry entry)
|
|
{
|
|
var options = new MemoryCacheEntryOptions
|
|
{
|
|
AbsoluteExpiration = entry.ExpiresAt,
|
|
};
|
|
_memoryCache.Set(CacheKey, entry, options);
|
|
}
|
|
|
|
private sealed record CacheEntry(
|
|
VexProvider Provider,
|
|
DateTimeOffset FetchedAt,
|
|
DateTimeOffset ExpiresAt,
|
|
string? ETag,
|
|
bool FromOffline)
|
|
{
|
|
public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
|
|
}
|
|
}
|
|
|
|
public sealed record CiscoProviderMetadataResult(
|
|
VexProvider Provider,
|
|
DateTimeOffset FetchedAt,
|
|
bool FromOfflineSnapshot,
|
|
bool ServedFromCache);
|
|
|
|
#region document models
|
|
|
|
internal sealed class ProviderMetadataDocument
|
|
{
|
|
[System.Text.Json.Serialization.JsonPropertyName("metadata")]
|
|
public ProviderMetadataMetadata Metadata { get; set; } = new();
|
|
|
|
[System.Text.Json.Serialization.JsonPropertyName("discovery")]
|
|
public ProviderMetadataDiscovery? Discovery { get; set; }
|
|
|
|
[System.Text.Json.Serialization.JsonPropertyName("trust")]
|
|
public ProviderMetadataTrust? Trust { get; set; }
|
|
|
|
[System.Text.Json.Serialization.JsonPropertyName("distributions")]
|
|
public ProviderMetadataDistributions? Distributions { get; set; }
|
|
}
|
|
|
|
internal sealed class ProviderMetadataMetadata
|
|
{
|
|
[System.Text.Json.Serialization.JsonPropertyName("publisher")]
|
|
public ProviderMetadataPublisher Publisher { get; set; } = new();
|
|
}
|
|
|
|
internal sealed class ProviderMetadataPublisher
|
|
{
|
|
[System.Text.Json.Serialization.JsonPropertyName("name")]
|
|
public string? Name { get; set; }
|
|
|
|
[System.Text.Json.Serialization.JsonPropertyName("category")]
|
|
public string? Category { get; set; }
|
|
|
|
[System.Text.Json.Serialization.JsonPropertyName("contact_details")]
|
|
public ProviderMetadataPublisherContact ContactDetails { get; set; } = new();
|
|
}
|
|
|
|
internal sealed class ProviderMetadataPublisherContact
|
|
{
|
|
[System.Text.Json.Serialization.JsonPropertyName("id")]
|
|
public string? Id { get; set; }
|
|
}
|
|
|
|
internal sealed class ProviderMetadataDiscovery
|
|
{
|
|
[System.Text.Json.Serialization.JsonPropertyName("well_known")]
|
|
public Uri? WellKnown { get; set; }
|
|
|
|
[System.Text.Json.Serialization.JsonPropertyName("rolie")]
|
|
public Uri? RolIe { get; set; }
|
|
}
|
|
|
|
internal sealed class ProviderMetadataTrust
|
|
{
|
|
[System.Text.Json.Serialization.JsonPropertyName("weight")]
|
|
public double? Weight { get; set; }
|
|
|
|
[System.Text.Json.Serialization.JsonPropertyName("cosign")]
|
|
public ProviderMetadataTrustCosign? Cosign { get; set; }
|
|
|
|
[System.Text.Json.Serialization.JsonPropertyName("pgp_fingerprints")]
|
|
public string[]? PgpFingerprints { get; set; }
|
|
}
|
|
|
|
internal sealed class ProviderMetadataTrustCosign
|
|
{
|
|
[System.Text.Json.Serialization.JsonPropertyName("issuer")]
|
|
public string? Issuer { get; set; }
|
|
|
|
[System.Text.Json.Serialization.JsonPropertyName("identity_pattern")]
|
|
public string? IdentityPattern { get; set; }
|
|
}
|
|
|
|
internal sealed class ProviderMetadataDistributions
|
|
{
|
|
[System.Text.Json.Serialization.JsonPropertyName("directories")]
|
|
public string[]? Directories { get; set; }
|
|
}
|
|
|
|
#endregion
|