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

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.AirGap.Controller.Tests")]

View File

@@ -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;
}
}

View File

@@ -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));
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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" />