up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
3
src/AirGap/StellaOps.AirGap.Controller/AssemblyInfo.cs
Normal file
3
src/AirGap/StellaOps.AirGap.Controller/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.AirGap.Controller.Tests")]
|
||||
@@ -1,10 +1,12 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.DependencyInjection;
|
||||
@@ -14,24 +16,33 @@ public static class AirGapControllerServiceCollectionExtensions
|
||||
public static IServiceCollection AddAirGapController(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<AirGapControllerMongoOptions>(configuration.GetSection("AirGap:Mongo"));
|
||||
services.Configure<AirGapStartupOptions>(configuration.GetSection("AirGap:Startup"));
|
||||
|
||||
services.AddSingleton<AirGapTelemetry>();
|
||||
services.AddSingleton<StalenessCalculator>();
|
||||
services.AddSingleton<AirGapStateService>();
|
||||
services.AddSingleton<TufMetadataValidator>();
|
||||
services.AddSingleton<RootRotationPolicy>();
|
||||
|
||||
services.AddSingleton<IAirGapStateStore>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AirGapControllerMongoOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILogger<MongoAirGapStateStore>>();
|
||||
if (string.IsNullOrWhiteSpace(opts.ConnectionString))
|
||||
{
|
||||
logger.LogInformation("AirGap controller using in-memory state store (Mongo connection string not configured).");
|
||||
return new InMemoryAirGapStateStore();
|
||||
}
|
||||
|
||||
var mongoClient = new MongoClient(opts.ConnectionString);
|
||||
var database = mongoClient.GetDatabase(string.IsNullOrWhiteSpace(opts.Database) ? "stellaops_airgap" : opts.Database);
|
||||
var collection = MongoAirGapStateStore.EnsureCollection(database);
|
||||
logger.LogInformation("AirGap controller using Mongo state store (db={Database}, collection={Collection}).", opts.Database, opts.Collection);
|
||||
return new MongoAirGapStateStore(collection);
|
||||
});
|
||||
|
||||
services.AddHostedService<AirGapStartupDiagnosticsHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,13 @@ internal static class AirGapEndpoints
|
||||
ClaimsPrincipal user,
|
||||
AirGapStateService service,
|
||||
TimeProvider timeProvider,
|
||||
AirGapTelemetry telemetry,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var status = await service.GetStatusAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
|
||||
telemetry.RecordStatus(tenantId, status);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
@@ -50,6 +52,7 @@ internal static class AirGapEndpoints
|
||||
AirGapStateService service,
|
||||
StalenessCalculator stalenessCalculator,
|
||||
TimeProvider timeProvider,
|
||||
AirGapTelemetry telemetry,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -65,6 +68,7 @@ internal static class AirGapEndpoints
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, cancellationToken);
|
||||
var status = new AirGapStatus(state, stalenessCalculator.Evaluate(anchor, budget, now), now);
|
||||
telemetry.RecordSeal(tenantId, status);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
@@ -72,12 +76,14 @@ internal static class AirGapEndpoints
|
||||
ClaimsPrincipal user,
|
||||
AirGapStateService service,
|
||||
TimeProvider timeProvider,
|
||||
AirGapTelemetry telemetry,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var state = await service.UnsealAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
|
||||
var status = new AirGapStatus(state, StalenessEvaluation.Unknown, timeProvider.GetUtcNow());
|
||||
telemetry.RecordUnseal(tenantId, status);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed record AirGapStatusResponse(
|
||||
string? PolicyHash,
|
||||
TimeAnchor TimeAnchor,
|
||||
StalenessEvaluation Staleness,
|
||||
long DriftSeconds,
|
||||
long SecondsRemaining,
|
||||
DateTimeOffset LastTransitionAt,
|
||||
DateTimeOffset EvaluatedAt)
|
||||
{
|
||||
@@ -20,6 +22,8 @@ public sealed record AirGapStatusResponse(
|
||||
status.State.PolicyHash,
|
||||
status.State.TimeAnchor,
|
||||
status.Staleness,
|
||||
status.Staleness.AgeSeconds,
|
||||
status.Staleness.SecondsRemaining,
|
||||
status.State.LastTransitionAt,
|
||||
status.EvaluatedAt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.AirGap.Controller.Options;
|
||||
|
||||
public sealed class AirGapStartupOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant to validate at startup. Defaults to single-tenant controller deployment.
|
||||
/// </summary>
|
||||
public string TenantId { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Optional egress allowlist. When null, startup diagnostics consider it missing.
|
||||
/// </summary>
|
||||
public string[]? EgressAllowlist { get; set; }
|
||||
= null;
|
||||
|
||||
/// <summary>
|
||||
/// Trust material required to prove bundles and egress policy inputs are present.
|
||||
/// </summary>
|
||||
public TrustMaterialOptions Trust { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Pending root rotation metadata; validated when pending keys exist.
|
||||
/// </summary>
|
||||
public RotationOptions Rotation { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TrustMaterialOptions
|
||||
{
|
||||
public string RootJsonPath { get; set; } = string.Empty;
|
||||
public string SnapshotJsonPath { get; set; } = string.Empty;
|
||||
public string TimestampJsonPath { get; set; } = string.Empty;
|
||||
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(RootJsonPath)
|
||||
&& !string.IsNullOrWhiteSpace(SnapshotJsonPath)
|
||||
&& !string.IsNullOrWhiteSpace(TimestampJsonPath);
|
||||
}
|
||||
|
||||
public sealed class RotationOptions
|
||||
{
|
||||
public Dictionary<string, string> ActiveKeys { get; set; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, string> PendingKeys { get; set; } = new(StringComparer.Ordinal);
|
||||
public List<string> ApproverIds { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Services;
|
||||
|
||||
internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
{
|
||||
private readonly IAirGapStateStore _stateStore;
|
||||
private readonly StalenessCalculator _stalenessCalculator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly AirGapStartupOptions _options;
|
||||
private readonly ILogger<AirGapStartupDiagnosticsHostedService> _logger;
|
||||
private readonly AirGapTelemetry _telemetry;
|
||||
private readonly TufMetadataValidator _tufValidator;
|
||||
private readonly RootRotationPolicy _rotationPolicy;
|
||||
|
||||
public AirGapStartupDiagnosticsHostedService(
|
||||
IAirGapStateStore stateStore,
|
||||
StalenessCalculator stalenessCalculator,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<AirGapStartupOptions> options,
|
||||
ILogger<AirGapStartupDiagnosticsHostedService> logger,
|
||||
AirGapTelemetry telemetry,
|
||||
TufMetadataValidator tufValidator,
|
||||
RootRotationPolicy rotationPolicy)
|
||||
{
|
||||
_stateStore = stateStore;
|
||||
_stalenessCalculator = stalenessCalculator;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_telemetry = telemetry;
|
||||
_tufValidator = tufValidator;
|
||||
_rotationPolicy = rotationPolicy;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = string.IsNullOrWhiteSpace(_options.TenantId) ? "default" : _options.TenantId;
|
||||
var state = await _stateStore.GetAsync(tenantId, cancellationToken);
|
||||
|
||||
if (!state.Sealed)
|
||||
{
|
||||
_logger.LogInformation("AirGap startup diagnostics skipped: tenant {TenantId} not sealed.", tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var staleness = _stalenessCalculator.Evaluate(state.TimeAnchor, state.StalenessBudget, now);
|
||||
var failures = new List<string>();
|
||||
|
||||
if (_options.EgressAllowlist is null)
|
||||
{
|
||||
failures.Add("egress-allowlist-missing");
|
||||
}
|
||||
|
||||
if (state.TimeAnchor == TimeAnchor.Unknown)
|
||||
{
|
||||
failures.Add("time-anchor-missing");
|
||||
}
|
||||
else if (staleness.IsBreach)
|
||||
{
|
||||
failures.Add("time-anchor-stale");
|
||||
}
|
||||
|
||||
var trustResult = ValidateTrustMaterials(_options.Trust);
|
||||
if (!trustResult.IsValid)
|
||||
{
|
||||
failures.Add($"trust:{trustResult.Reason}");
|
||||
}
|
||||
|
||||
var rotationResult = ValidateRotation(_options.Rotation);
|
||||
if (!rotationResult.IsValid)
|
||||
{
|
||||
failures.Add($"rotation:{rotationResult.Reason}");
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
var reason = string.Join(',', failures);
|
||||
_telemetry.RecordStartupBlocked(tenantId, reason, staleness);
|
||||
_logger.LogCritical(
|
||||
"AirGap sealed-startup blocked tenant={TenantId} reasons={Reasons} policy_hash={PolicyHash} anchor_digest={Anchor}",
|
||||
tenantId,
|
||||
reason,
|
||||
state.PolicyHash,
|
||||
state.TimeAnchor.TokenDigest);
|
||||
throw new InvalidOperationException($"sealed-startup-blocked:{reason}");
|
||||
}
|
||||
|
||||
_telemetry.RecordStartupPassed(tenantId, staleness, _options.EgressAllowlist?.Length ?? 0);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private StartupCheckResult ValidateTrustMaterials(TrustMaterialOptions trust)
|
||||
{
|
||||
if (!trust.IsConfigured)
|
||||
{
|
||||
return StartupCheckResult.Failure("trust-roots-missing");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rootJson = File.ReadAllText(trust.RootJsonPath);
|
||||
var snapshotJson = File.ReadAllText(trust.SnapshotJsonPath);
|
||||
var timestampJson = File.ReadAllText(trust.TimestampJsonPath);
|
||||
var result = _tufValidator.Validate(rootJson, snapshotJson, timestampJson);
|
||||
return result.IsValid
|
||||
? StartupCheckResult.Success()
|
||||
: StartupCheckResult.Failure(result.Reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StartupCheckResult.Failure($"trust-read-failed:{ex.GetType().Name.ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
private StartupCheckResult ValidateRotation(RotationOptions rotation)
|
||||
{
|
||||
if (rotation.PendingKeys.Count == 0)
|
||||
{
|
||||
return StartupCheckResult.Success();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var active = DecodeKeys(rotation.ActiveKeys);
|
||||
var pending = DecodeKeys(rotation.PendingKeys);
|
||||
var result = _rotationPolicy.Validate(active, pending, rotation.ApproverIds);
|
||||
return result.IsValid
|
||||
? StartupCheckResult.Success()
|
||||
: StartupCheckResult.Failure(result.Reason);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return StartupCheckResult.Failure("rotation-key-invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, byte[]> DecodeKeys(Dictionary<string, string> source)
|
||||
{
|
||||
var decoded = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
foreach (var kvp in source)
|
||||
{
|
||||
decoded[kvp.Key] = Convert.FromBase64String(kvp.Value);
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
private sealed record StartupCheckResult(bool IsValid, string Reason)
|
||||
{
|
||||
public static StartupCheckResult Success() => new(true, "ok");
|
||||
public static StartupCheckResult Failure(string reason) => new(false, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Centralised metrics + trace hooks for the AirGap controller.
|
||||
/// </summary>
|
||||
public sealed class AirGapTelemetry
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.AirGap.Controller", "1.0.0");
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.AirGap.Controller");
|
||||
|
||||
private static readonly Counter<long> SealCounter = Meter.CreateCounter<long>("airgap_seal_total");
|
||||
private static readonly Counter<long> UnsealCounter = Meter.CreateCounter<long>("airgap_unseal_total");
|
||||
private static readonly Counter<long> StartupBlockedCounter = Meter.CreateCounter<long>("airgap_startup_blocked_total");
|
||||
|
||||
private readonly ConcurrentDictionary<string, (long Age, long Budget)> _latestByTenant = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly ObservableGauge<long> _anchorAgeGauge;
|
||||
private readonly ObservableGauge<long> _budgetGauge;
|
||||
private readonly ILogger<AirGapTelemetry> _logger;
|
||||
|
||||
public AirGapTelemetry(ILogger<AirGapTelemetry> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_anchorAgeGauge = Meter.CreateObservableGauge("airgap_time_anchor_age_seconds", ObserveAges);
|
||||
_budgetGauge = Meter.CreateObservableGauge("airgap_staleness_budget_seconds", ObserveBudgets);
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<long>> ObserveAges()
|
||||
{
|
||||
foreach (var kvp in _latestByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.Age, new KeyValuePair<string, object?>("tenant", kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<long>> ObserveBudgets()
|
||||
{
|
||||
foreach (var kvp in _latestByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.Budget, new KeyValuePair<string, object?>("tenant", kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordStatus(string tenantId, AirGapStatus status)
|
||||
{
|
||||
_latestByTenant[tenantId] = (status.Staleness.AgeSeconds, status.Staleness.BreachSeconds);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("airgap.status.read");
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
activity?.SetTag("sealed", status.State.Sealed);
|
||||
activity?.SetTag("policy_hash", status.State.PolicyHash);
|
||||
activity?.SetTag("anchor_source", status.State.TimeAnchor.Source);
|
||||
activity?.SetTag("staleness_age_seconds", status.Staleness.AgeSeconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"airgap.status.read tenant={Tenant} sealed={Sealed} policy_hash={PolicyHash} anchor_source={Source} age_seconds={Age}",
|
||||
tenantId,
|
||||
status.State.Sealed,
|
||||
status.State.PolicyHash,
|
||||
status.State.TimeAnchor.Source,
|
||||
status.Staleness.AgeSeconds);
|
||||
}
|
||||
|
||||
public void RecordSeal(string tenantId, AirGapStatus status)
|
||||
{
|
||||
SealCounter.Add(1, new TagList { { "tenant", tenantId }, { "sealed", true } });
|
||||
RecordStatus(tenantId, status);
|
||||
|
||||
_logger.LogInformation(
|
||||
"airgap.sealed tenant={Tenant} policy_hash={PolicyHash} anchor_source={Source} anchor_digest={Digest} age_seconds={Age}",
|
||||
tenantId,
|
||||
status.State.PolicyHash,
|
||||
status.State.TimeAnchor.Source,
|
||||
status.State.TimeAnchor.TokenDigest,
|
||||
status.Staleness.AgeSeconds);
|
||||
}
|
||||
|
||||
public void RecordUnseal(string tenantId, AirGapStatus status)
|
||||
{
|
||||
UnsealCounter.Add(1, new TagList { { "tenant", tenantId }, { "sealed", false } });
|
||||
RecordStatus(tenantId, status);
|
||||
|
||||
_logger.LogInformation(
|
||||
"airgap.unsealed tenant={Tenant} last_transition_at={TransitionAt}",
|
||||
tenantId,
|
||||
status.State.LastTransitionAt);
|
||||
}
|
||||
|
||||
public void RecordStartupBlocked(string tenantId, string reason, StalenessEvaluation staleness)
|
||||
{
|
||||
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
StartupBlockedCounter.Add(1, new TagList { { "tenant", tenantId }, { "reason", reason } });
|
||||
_logger.LogCritical("airgap.startup.validation failed tenant={Tenant} reason={Reason}", tenantId, reason);
|
||||
}
|
||||
|
||||
public void RecordStartupPassed(string tenantId, StalenessEvaluation staleness, int allowlistCount)
|
||||
{
|
||||
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
using var activity = ActivitySource.StartActivity("airgap.startup.validation");
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
activity?.SetTag("result", "success");
|
||||
activity?.SetTag("allowlist_count", allowlistCount);
|
||||
activity?.SetTag("staleness_age_seconds", staleness.AgeSeconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"airgap.startup.validation passed tenant={Tenant} allowlist_count={AllowlistCount} anchor_age_seconds={Age}",
|
||||
tenantId,
|
||||
allowlistCount,
|
||||
staleness.AgeSeconds);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
|
||||
Reference in New Issue
Block a user