From 87eeeeca7f1130e5aaae0d9d1076ac6e0341c830 Mon Sep 17 00:00:00 2001 From: Jacob Reimers Date: Sat, 18 Apr 2026 16:40:36 +0200 Subject: [PATCH 1/5] Add OpenTelemetry metrics feature --- OpenTelemetryMetricsTraces.md | 200 +++++++++++ .../AcmeInstruments.cs | 67 ++++ .../ActivityNames.cs | 19 ++ .../Endpoints/AccountEndpoints.cs | 36 +- .../Endpoints/DirectoryEndpoints.cs | 50 ++- .../Endpoints/NonceEndpoints.cs | 46 ++- .../Endpoints/OrderEndpoints.cs | 77 ++++- .../Endpoints/RevocationEndpoints.cs | 31 +- src/opencertserver.acme.server/MetricNames.cs | 66 ++++ src/opencertserver.acme.server/TagKeys.cs | 24 ++ .../Workers/ValidationWorker.cs | 53 ++- src/opencertserver.ca.server/ActivityNames.cs | 16 + src/opencertserver.ca.server/CaInstruments.cs | 49 +++ .../Handlers/CrlHandler.cs | 28 +- .../Handlers/CsrHandler.cs | 47 ++- .../Handlers/InventoryHandler.cs | 38 ++- .../Handlers/OcspHandler.cs | 28 +- .../Handlers/RevocationHandler.cs | 93 ++++-- src/opencertserver.ca.server/MetricNames.cs | 47 +++ src/opencertserver.ca.server/TagKeys.cs | 27 ++ .../ActivityNames.cs | 15 + .../EstInstruments.cs | 42 +++ .../Handlers/CaCertHandler.cs | 39 ++- .../Handlers/CsrAttributesHandler.cs | 23 +- .../Handlers/ServerKeyGenHandler.cs | 133 +++++--- .../Handlers/SimpleEnrollHandler.cs | 155 +++++---- .../Handlers/SimpleReEnrollHandler.cs | 183 ++++++----- src/opencertserver.est.server/MetricNames.cs | 41 +++ src/opencertserver.est.server/TagKeys.cs | 21 ++ .../Features/OpenTelemetryMetrics.feature | 31 ++ .../Features/OpenTelemetryMetrics.feature.cs | 311 ++++++++++++++++++ .../StepDefinitions/OpenTelemetryMetrics.cs | 120 +++++++ .../OpenTelemetryCollectorContainer.cs | 141 ++++++++ .../Support/otel-collector-config.yaml | 30 ++ .../opencertserver.certserver.tests.csproj | 1 + 35 files changed, 2023 insertions(+), 305 deletions(-) create mode 100644 OpenTelemetryMetricsTraces.md create mode 100644 src/opencertserver.acme.server/AcmeInstruments.cs create mode 100644 src/opencertserver.acme.server/ActivityNames.cs create mode 100644 src/opencertserver.acme.server/MetricNames.cs create mode 100644 src/opencertserver.acme.server/TagKeys.cs create mode 100644 src/opencertserver.ca.server/ActivityNames.cs create mode 100644 src/opencertserver.ca.server/CaInstruments.cs create mode 100644 src/opencertserver.ca.server/MetricNames.cs create mode 100644 src/opencertserver.ca.server/TagKeys.cs create mode 100644 src/opencertserver.est.server/ActivityNames.cs create mode 100644 src/opencertserver.est.server/EstInstruments.cs create mode 100644 src/opencertserver.est.server/MetricNames.cs create mode 100644 src/opencertserver.est.server/TagKeys.cs create mode 100644 tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature create mode 100644 tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature.cs create mode 100644 tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs create mode 100644 tests/opencertserver.certserver.tests/Support/OpenTelemetryCollectorContainer.cs create mode 100644 tests/opencertserver.certserver.tests/Support/otel-collector-config.yaml diff --git a/OpenTelemetryMetricsTraces.md b/OpenTelemetryMetricsTraces.md new file mode 100644 index 0000000..de9b49e --- /dev/null +++ b/OpenTelemetryMetricsTraces.md @@ -0,0 +1,200 @@ +# OpenTelemetry Metrics and Traces for OpenCertServer + +This document lists relevant OpenTelemetry metrics and traces for OpenCertServer based on implemented RFCs (EST RFC 7030/8951/9908, ACME RFC 8555, OCSP RFC 6960, CRL RFC 5280) and codebase analysis. + +## Naming Strategy +- **Metrics**: `opencertserver.{protocol}.{operation}.{type}` where `type` is `requests` (counter), `successes` (counter), `failures` (counter), `duration` (histogram in seconds), `active` (gauge for ongoing operations). +- **Traces**: Span name `opencertserver.{protocol}.{operation}`, with sub-spans for internal steps like validation, signing, persistence. +- Protocols: `est`, `acme`, `ocsp`, `crl`, `ca` (for CA endpoints), `cli` (for CLI operations). + +## EST (RFC 7030) + +### Metrics +- `opencertserver.est.cacerts.requests`: Counter for /cacerts requests. +- `opencertserver.est.cacerts.successes`: Counter for successful /cacerts responses. +- `opencertserver.est.cacerts.failures`: Counter for failed /cacerts responses (e.g., internal errors). +- `opencertserver.est.cacerts.duration`: Histogram for request duration. +- `opencertserver.est.simpleenroll.requests`: Counter for /simpleenroll requests. +- `opencertserver.est.simpleenroll.successes`: Counter for successful enrollments. +- `opencertserver.est.simpleenroll.failures`: Counter for enrollment failures (e.g., invalid CSR, auth failure). +- `opencertserver.est.simpleenroll.duration`: Histogram for enrollment duration. +- `opencertserver.est.simplereenroll.requests`: Counter for /simplereenroll requests. +- `opencertserver.est.simplereenroll.successes`: Counter for successful re-enrollments. +- `opencertserver.est.simplereenroll.failures`: Counter for re-enrollment failures. +- `opencertserver.est.simplereenroll.duration`: Histogram for re-enrollment duration. +- `opencertserver.est.csrattrs.requests`: Counter for /csrattrs requests. +- `opencertserver.est.csrattrs.successes`: Counter for successful /csrattrs responses. +- `opencertserver.est.csrattrs.failures`: Counter for /csrattrs failures. +- `opencertserver.est.csrattrs.duration`: Histogram for /csrattrs duration. +- `opencertserver.est.serverkeygen.requests`: Counter for /serverkeygen requests. +- `opencertserver.est.serverkeygen.successes`: Counter for successful key generations. +- `opencertserver.est.serverkeygen.failures`: Counter for key generation failures. +- `opencertserver.est.serverkeygen.duration`: Histogram for key generation duration. + +### Traces +- `opencertserver.est.cacerts`: Span for /cacerts request, sub-spans for certificate chain retrieval and response encoding. +- `opencertserver.est.simpleenroll`: Span for enrollment, sub-spans for CSR parsing, validation, signing, certificate issuance. +- `opencertserver.est.simplereenroll`: Span for re-enrollment, sub-spans for authentication, CSR validation, signing. +- `opencertserver.est.csrattrs`: Span for CSR attributes retrieval. +- `opencertserver.est.serverkeygen`: Span for key generation, sub-spans for key pair creation, certificate signing. + +**Collection Location**: In `src/opencertserver.est.server/Handlers/` (e.g., `CaCertHandler.cs`, `SimpleEnrollHandler.cs`), wrap `Handle` methods with spans, record metrics at start/end. + +## ACME (RFC 8555) + +### Metrics +- `opencertserver.acme.directory.requests`: Counter for /directory requests. +- `opencertserver.acme.directory.successes`: Counter for successful directory responses. +- `opencertserver.acme.directory.failures`: Counter for directory failures. +- `opencertserver.acme.directory.duration`: Histogram for directory duration. +- `opencertserver.acme.newnonce.requests`: Counter for /new-nonce requests. +- `opencertserver.acme.newnonce.successes`: Counter for successful nonce responses. +- `opencertserver.acme.newnonce.failures`: Counter for nonce failures. +- `opencertserver.acme.newnonce.duration`: Histogram for nonce duration. +- `opencertserver.acme.newaccount.requests`: Counter for /new-account requests. +- `opencertserver.acme.newaccount.successes`: Counter for successful account creations. +- `opencertserver.acme.newaccount.failures`: Counter for account creation failures (e.g., invalid JWS). +- `opencertserver.acme.newaccount.duration`: Histogram for account creation duration. +- `opencertserver.acme.neworder.requests`: Counter for /new-order requests. +- `opencertserver.acme.neworder.successes`: Counter for successful order creations. +- `opencertserver.acme.neworder.failures`: Counter for order creation failures. +- `opencertserver.acme.neworder.duration`: Histogram for order creation duration. +- `opencertserver.acme.orderfinalize.requests`: Counter for /order/{id}/finalize requests. +- `opencertserver.acme.orderfinalize.successes`: Counter for successful finalizes. +- `opencertserver.acme.orderfinalize.failures`: Counter for finalize failures. +- `opencertserver.acme.orderfinalize.duration`: Histogram for finalize duration. +- `opencertserver.acme.certificate.requests`: Counter for /order/{id}/certificate requests. +- `opencertserver.acme.certificate.successes`: Counter for successful certificate downloads. +- `opencertserver.acme.certificate.failures`: Counter for certificate download failures. +- `opencertserver.acme.certificate.duration`: Histogram for certificate duration. +- `opencertserver.acme.challengevalidation.requests`: Counter for challenge validation attempts. +- `opencertserver.acme.challengevalidation.successes`: Counter for successful validations. +- `opencertserver.acme.challengevalidation.failures`: Counter for validation failures. +- `opencertserver.acme.challengevalidation.duration`: Histogram for validation duration. +- `opencertserver.acme.challengevalidation.active`: Gauge for active pending challenges. +- `opencertserver.acme.keychange.requests`: Counter for /key-change requests. +- `opencertserver.acme.keychange.successes`: Counter for successful key rollovers. +- `opencertserver.acme.keychange.failures`: Counter for key rollover failures. +- `opencertserver.acme.keychange.duration`: Histogram for key rollover duration. +- `opencertserver.acme.revoke.requests`: Counter for /revoke-cert requests. +- `opencertserver.acme.revoke.successes`: Counter for successful revocations. +- `opencertserver.acme.revoke.failures`: Counter for revocation failures. +- `opencertserver.acme.revoke.duration`: Histogram for revocation duration. + +### Traces +- `opencertserver.acme.directory`: Span for directory request. +- `opencertserver.acme.newnonce`: Span for nonce request. +- `opencertserver.acme.newaccount`: Span for account creation, sub-spans for JWS validation, account storage. +- `opencertserver.acme.neworder`: Span for order creation, sub-spans for authorization setup. +- `opencertserver.acme.orderfinalize`: Span for finalize, sub-spans for CSR validation, certificate issuance. +- `opencertserver.acme.certificate`: Span for certificate download. +- `opencertserver.acme.challengevalidation`: Span for validation, sub-spans for HTTP/DNS checks. +- `opencertserver.acme.keychange`: Span for key rollover, sub-spans for signature verification. +- `opencertserver.acme.revoke`: Span for revocation, sub-spans for certificate validation. + +**Collection Location**: In `src/opencertserver.acme.server/Endpoints/` (e.g., `AccountEndpoints.cs`, `OrderEndpoints.cs`), wrap endpoint methods with spans, record metrics. + +## OCSP (RFC 6960) + +### Metrics +- `opencertserver.ocsp.request.requests`: Counter for OCSP requests. +- `opencertserver.ocsp.request.successes`: Counter for successful responses (good/revoked/unknown). +- `opencertserver.ocsp.request.failures`: Counter for failures (malformed, internalError, tryLater). +- `opencertserver.ocsp.request.duration`: Histogram for request processing duration. + +### Traces +- `opencertserver.ocsp.request`: Span for OCSP request, sub-spans for request parsing, certificate lookup, response signing. + +**Collection Location**: In `src/opencertserver.ca.server/Handlers/OcspHandler.cs`, wrap `Handle` method. + +## CRL (RFC 5280) + +### Metrics +- `opencertserver.crl.request.requests`: Counter for CRL requests. +- `opencertserver.crl.request.successes`: Counter for successful CRL responses. +- `opencertserver.crl.request.failures`: Counter for CRL failures. +- `opencertserver.crl.request.duration`: Histogram for request duration. +- `opencertserver.crl.generation.requests`: Counter for CRL generation triggers. +- `opencertserver.crl.generation.successes`: Counter for successful generations. +- `opencertserver.crl.generation.failures`: Counter for generation failures. +- `opencertserver.crl.generation.duration`: Histogram for generation duration. + +### Traces +- `opencertserver.crl.request`: Span for CRL request. +- `opencertserver.crl.generation`: Span for CRL generation, sub-spans for certificate list building, signing. + +**Collection Location**: In CRL handler (likely in `src/opencertserver.ca.server/`), wrap request and generation methods. + +## CA Endpoints + +### Metrics +- `opencertserver.ca.csr.requests`: Counter for /ca/csr POST requests. +- `opencertserver.ca.csr.successes`: Counter for successful CSR signings. +- `opencertserver.ca.csr.failures`: Counter for CSR signing failures. +- `opencertserver.ca.csr.duration`: Histogram for CSR signing duration. +- `opencertserver.ca.revoke.requests`: Counter for /ca/revoke DELETE requests. +- `opencertserver.ca.revoke.successes`: Counter for successful revocations. +- `opencertserver.ca.revoke.failures`: Counter for revocation failures. +- `opencertserver.ca.revoke.duration`: Histogram for revocation duration. +- `opencertserver.ca.inventory.requests`: Counter for /ca/inventory GET requests. +- `opencertserver.ca.inventory.successes`: Counter for successful inventory responses. +- `opencertserver.ca.inventory.failures`: Counter for inventory failures. +- `opencertserver.ca.inventory.duration`: Histogram for inventory duration. + +### Traces +- `opencertserver.ca.csr`: Span for CSR signing, sub-spans for validation, signing. +- `opencertserver.ca.revoke`: Span for revocation, sub-spans for proof verification. +- `opencertserver.ca.inventory`: Span for inventory retrieval. + +**Collection Location**: In `src/opencertserver.ca.server/`, relevant handlers. + +## CLI Operations + +### Metrics +- `opencertserver.cli.generatekeys.requests`: Counter for generate-keys commands. +- `opencertserver.cli.generatekeys.successes`: Counter for successful key generations. +- `opencertserver.cli.generatekeys.failures`: Counter for failures. +- `opencertserver.cli.generatekeys.duration`: Histogram for duration. +- `opencertserver.cli.printcert.requests`: Counter for print-cert commands. +- `opencertserver.cli.printcert.successes`: Counter for successful prints. +- `opencertserver.cli.printcert.failures`: Counter for failures. +- `opencertserver.cli.printcert.duration`: Histogram for duration. +- `opencertserver.cli.createcsr.requests`: Counter for create-csr commands. +- `opencertserver.cli.createcsr.successes`: Counter for successful CSRs. +- `opencertserver.cli.createcsr.failures`: Counter for failures. +- `opencertserver.cli.createcsr.duration`: Histogram for duration. +- `opencertserver.cli.signcsr.requests`: Counter for sign-csr commands. +- `opencertserver.cli.signcsr.successes`: Counter for successful signings. +- `opencertserver.cli.signcsr.failures`: Counter for failures. +- `opencertserver.cli.signcsr.duration`: Histogram for duration. +- `opencertserver.cli.estenroll.requests`: Counter for est-enroll commands. +- `opencertserver.cli.estenroll.successes`: Counter for successful enrollments. +- `opencertserver.cli.estenroll.failures`: Counter for failures. +- `opencertserver.cli.estenroll.duration`: Histogram for duration. +- `opencertserver.cli.estreerenroll.requests`: Counter for est-reenroll commands. +- `opencertserver.cli.estreerenroll.successes`: Counter for successful re-enrollments. +- `opencertserver.cli.estreerenroll.failures`: Counter for failures. +- `opencertserver.cli.estreerenroll.duration`: Histogram for duration. +- `opencertserver.cli.estservercertificates.requests`: Counter for est-server-certificates commands. +- `opencertserver.cli.estservercertificates.successes`: Counter for successful fetches. +- `opencertserver.cli.estservercertificates.failures`: Counter for failures. +- `opencertserver.cli.estservercertificates.duration`: Histogram for duration. + +### Traces +- `opencertserver.cli.generatekeys`: Span for key generation. +- `opencertserver.cli.printcert`: Span for certificate printing. +- `opencertserver.cli.createcsr`: Span for CSR creation. +- `opencertserver.cli.signcsr`: Span for CSR signing. +- `opencertserver.cli.estenroll`: Span for EST enrollment, sub-spans for client operations. +- `opencertserver.cli.estreerenroll`: Span for EST re-enrollment. +- `opencertserver.cli.estservercertificates`: Span for CA cert fetch. + +**Collection Location**: In `src/opencertserver.cli/`, wrap command handlers. + +## Additional Cross-Cutting Metrics +- `opencertserver.auth.failures`: Counter for authentication failures across protocols. +- `opencertserver.tls.errors`: Counter for TLS-related errors. +- `opencertserver.errors.internal`: Counter for internal server errors. + +**Collection Location**: In middleware or base handlers. +/Users/jacobreimers/code/opencertserver/OpenTelemetryMetricsTraces.md diff --git a/src/opencertserver.acme.server/AcmeInstruments.cs b/src/opencertserver.acme.server/AcmeInstruments.cs new file mode 100644 index 0000000..a18c17b --- /dev/null +++ b/src/opencertserver.acme.server/AcmeInstruments.cs @@ -0,0 +1,67 @@ +namespace OpenCertServer.Acme.Server; + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +/// OpenTelemetry instruments for all ACME request handlers (RFC 8555). +internal static class AcmeInstruments +{ + private static readonly Meter Meter = new(MetricNames.MeterName, "1.0.0"); + + internal static readonly ActivitySource ActivitySource = new(MetricNames.MeterName, "1.0.0"); + + // /directory + internal static readonly Counter DirectoryRequests = Meter.CreateCounter (MetricNames.DirectoryRequests, description: "Total /directory requests"); + internal static readonly Counter DirectorySuccesses = Meter.CreateCounter (MetricNames.DirectorySuccesses, description: "Successful /directory responses"); + internal static readonly Counter DirectoryFailures = Meter.CreateCounter (MetricNames.DirectoryFailures, description: "Failed /directory responses"); + internal static readonly Histogram DirectoryDuration = Meter.CreateHistogram (MetricNames.DirectoryDuration, "s", "Duration of /directory requests"); + + // /new-nonce + internal static readonly Counter NewNonceRequests = Meter.CreateCounter (MetricNames.NewNonceRequests); + internal static readonly Counter NewNonceSuccesses = Meter.CreateCounter (MetricNames.NewNonceSuccesses); + internal static readonly Counter NewNonceFailures = Meter.CreateCounter (MetricNames.NewNonceFailures); + internal static readonly Histogram NewNonceDuration = Meter.CreateHistogram (MetricNames.NewNonceDuration, "s"); + + // /new-account + internal static readonly Counter NewAccountRequests = Meter.CreateCounter (MetricNames.NewAccountRequests, description: "Total /new-account requests"); + internal static readonly Counter NewAccountSuccesses = Meter.CreateCounter (MetricNames.NewAccountSuccesses, description: "Successful /new-account responses"); + internal static readonly Counter NewAccountFailures = Meter.CreateCounter (MetricNames.NewAccountFailures, description: "Failed /new-account responses"); + internal static readonly Histogram NewAccountDuration = Meter.CreateHistogram (MetricNames.NewAccountDuration, "s", "Duration of /new-account requests"); + + // /new-order + internal static readonly Counter NewOrderRequests = Meter.CreateCounter (MetricNames.NewOrderRequests, description: "Total /new-order requests"); + internal static readonly Counter NewOrderSuccesses = Meter.CreateCounter (MetricNames.NewOrderSuccesses, description: "Successful /new-order responses"); + internal static readonly Counter NewOrderFailures = Meter.CreateCounter (MetricNames.NewOrderFailures, description: "Failed /new-order responses"); + internal static readonly Histogram NewOrderDuration = Meter.CreateHistogram (MetricNames.NewOrderDuration, "s", "Duration of /new-order requests"); + + // /order/{id}/finalize + internal static readonly Counter OrderFinalizeRequests = Meter.CreateCounter (MetricNames.OrderFinalizeRequests); + internal static readonly Counter OrderFinalizeSuccesses = Meter.CreateCounter (MetricNames.OrderFinalizeSuccesses); + internal static readonly Counter OrderFinalizeFailures = Meter.CreateCounter (MetricNames.OrderFinalizeFailures); + internal static readonly Histogram OrderFinalizeDuration = Meter.CreateHistogram (MetricNames.OrderFinalizeDuration, "s"); + + // /order/{id}/certificate + internal static readonly Counter CertificateRequests = Meter.CreateCounter (MetricNames.CertificateRequests); + internal static readonly Counter CertificateSuccesses = Meter.CreateCounter (MetricNames.CertificateSuccesses); + internal static readonly Counter CertificateFailures = Meter.CreateCounter (MetricNames.CertificateFailures); + internal static readonly Histogram CertificateDuration = Meter.CreateHistogram (MetricNames.CertificateDuration, "s"); + + // Challenge validation (ValidationWorker) + internal static readonly Counter ChallengeValidationRequests = Meter.CreateCounter (MetricNames.ChallengeValidationRequests, description: "Total ACME challenge validation attempts"); + internal static readonly Counter ChallengeValidationSuccesses = Meter.CreateCounter (MetricNames.ChallengeValidationSuccesses, description: "Successful ACME challenge validations"); + internal static readonly Counter ChallengeValidationFailures = Meter.CreateCounter (MetricNames.ChallengeValidationFailures, description: "Failed ACME challenge validations"); + internal static readonly Histogram ChallengeValidationDuration = Meter.CreateHistogram (MetricNames.ChallengeValidationDuration, "s", "Duration of challenge validation"); + internal static readonly UpDownCounter ChallengeValidationActive = Meter.CreateUpDownCounter (MetricNames.ChallengeValidationActive, description: "Active pending ACME challenge validations"); + + // /key-change + internal static readonly Counter KeyChangeRequests = Meter.CreateCounter (MetricNames.KeyChangeRequests); + internal static readonly Counter KeyChangeSuccesses = Meter.CreateCounter (MetricNames.KeyChangeSuccesses); + internal static readonly Counter KeyChangeFailures = Meter.CreateCounter (MetricNames.KeyChangeFailures); + internal static readonly Histogram KeyChangeDuration = Meter.CreateHistogram (MetricNames.KeyChangeDuration, "s"); + + // /revoke-cert + internal static readonly Counter RevokeRequests = Meter.CreateCounter (MetricNames.RevokeRequests); + internal static readonly Counter RevokeSuccesses = Meter.CreateCounter (MetricNames.RevokeSuccesses); + internal static readonly Counter RevokeFailures = Meter.CreateCounter (MetricNames.RevokeFailures); + internal static readonly Histogram RevokeDuration = Meter.CreateHistogram (MetricNames.RevokeDuration, "s"); +} diff --git a/src/opencertserver.acme.server/ActivityNames.cs b/src/opencertserver.acme.server/ActivityNames.cs new file mode 100644 index 0000000..03515cb --- /dev/null +++ b/src/opencertserver.acme.server/ActivityNames.cs @@ -0,0 +1,19 @@ +namespace OpenCertServer.Acme.Server; + +/// +/// Centralised activity (span) name constants for all ACME OpenTelemetry traces. +/// All spans MUST reference these constants; never use inline string literals for activity names. +/// +internal static class ActivityNames +{ + internal const string Directory = "opencertserver.acme.directory"; + internal const string NewNonce = "opencertserver.acme.newnonce"; + internal const string NewAccount = "opencertserver.acme.newaccount"; + internal const string NewOrder = "opencertserver.acme.neworder"; + internal const string OrderFinalize = "opencertserver.acme.orderfinalize"; + internal const string Certificate = "opencertserver.acme.certificate"; + internal const string ChallengeValidation = "opencertserver.acme.challengevalidation"; + internal const string KeyChange = "opencertserver.acme.keychange"; + internal const string Revoke = "opencertserver.acme.revoke"; +} + diff --git a/src/opencertserver.acme.server/Endpoints/AccountEndpoints.cs b/src/opencertserver.acme.server/Endpoints/AccountEndpoints.cs index 710de60..15d3c0e 100644 --- a/src/opencertserver.acme.server/Endpoints/AccountEndpoints.cs +++ b/src/opencertserver.acme.server/Endpoints/AccountEndpoints.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Acme.Server.Endpoints; +using System.Diagnostics; using System.Text; using System.Text.Json; using CertesSlim.Acme.Resource; @@ -115,6 +116,11 @@ private static async Task KeyChangeHandler( LinkGenerator links, CancellationToken cancellationToken) { + AcmeInstruments.KeyChangeRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.KeyChange); + try + { // RFC 8555 §7.3.5: The outer JWS is signed by the CURRENT (old) account key and // must identify the account with a "kid" header parameter. // The request validation middleware has already verified the outer signature via kid. @@ -183,7 +189,19 @@ private static async Task KeyChangeHandler( account = await accountService.ChangeKey(account, newKey, cancellationToken).ConfigureAwait(false); context.Response.Headers.Location = accountUrl; - return Results.Ok(CreateAccountResponse(context, links, account)); + var keyChangeResult = Results.Ok(CreateAccountResponse(context, links, account)); + AcmeInstruments.KeyChangeSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + AcmeInstruments.KeyChangeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return keyChangeResult; + } + catch (Exception ex) + { + AcmeInstruments.KeyChangeFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + AcmeInstruments.KeyChangeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + throw; + } } private static async Task NewAccountHandler( @@ -195,6 +213,11 @@ private static async Task NewAccountHandler( [FromServices] LinkGenerator links, CancellationToken cancellationToken) { + AcmeInstruments.NewAccountRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.NewAccount); + try + { var options = optionsAccessor.Value; var header = JsonSerializer.Deserialize(Base64UrlEncoder.Decode(jwsPayload.Protected)!, AcmeSerializerContext.Default.AcmeHeader)!; @@ -256,7 +279,18 @@ private static async Task NewAccountHandler( payload.TermsOfServiceAgreed == true, externalAccountId, cancellationToken).ConfigureAwait(false); var createdAccountResponse = CreateAccountResponse(context, links, createdAccount); var createdAccountUrl = GetAccountUrl(context, links, createdAccount.AccountId); + AcmeInstruments.NewAccountSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + AcmeInstruments.NewAccountDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); return Results.Created(createdAccountUrl, createdAccountResponse); + } + catch (Exception ex) + { + AcmeInstruments.NewAccountFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + AcmeInstruments.NewAccountDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + throw; + } } private static Account CreateAccountResponse( diff --git a/src/opencertserver.acme.server/Endpoints/DirectoryEndpoints.cs b/src/opencertserver.acme.server/Endpoints/DirectoryEndpoints.cs index c862c97..4f0d008 100644 --- a/src/opencertserver.acme.server/Endpoints/DirectoryEndpoints.cs +++ b/src/opencertserver.acme.server/Endpoints/DirectoryEndpoints.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Acme.Server.Endpoints; +using System.Diagnostics; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -16,20 +17,43 @@ public static IEndpointRouteBuilder MapDirectoryEndpoints(this IEndpointRouteBui private static IResult GetDirectoryHandler(HttpContext context, IOptions optionsAccessor, LinkGenerator links) { - var options = optionsAccessor.Value; - - var directory = new OpenCertServer.Acme.Abstractions.HttpModel.Directory + AcmeInstruments.DirectoryRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.Directory); + try { - NewNonce = GetUrl("NewNonce"), - NewAccount = GetUrl("NewAccount"), - NewOrder = GetUrl("NewOrder"), - NewAuthz = null, - RevokeCert = GetUrl("RevokeCert"), - KeyChange = GetUrl("KeyChange"), - Meta = new OpenCertServer.Acme.Abstractions.HttpModel.DirectoryMetadata { ExternalAccountRequired = options.ExternalAccountRequired, CAAIdentities = null, TermsOfService = options.TOS.RequireAgreement ? options.TOS.Url : null, Website = options.WebsiteUrl } - }; - return Results.Ok(directory); + var options = optionsAccessor.Value; + var directory = new OpenCertServer.Acme.Abstractions.HttpModel.Directory + { + NewNonce = GetUrl("NewNonce"), + NewAccount = GetUrl("NewAccount"), + NewOrder = GetUrl("NewOrder"), + NewAuthz = null, + RevokeCert = GetUrl("RevokeCert"), + KeyChange = GetUrl("KeyChange"), + Meta = new OpenCertServer.Acme.Abstractions.HttpModel.DirectoryMetadata + { + ExternalAccountRequired = options.ExternalAccountRequired, + CAAIdentities = null, + TermsOfService = options.TOS.RequireAgreement ? options.TOS.Url : null, + Website = options.WebsiteUrl + } + }; + AcmeInstruments.DirectorySuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + return Results.Ok(directory); - string? GetUrl(string routeName) => links.GetUriByName(context, routeName, values: null, scheme: Uri.UriSchemeHttps); + string? GetUrl(string routeName) => links.GetUriByName(context, routeName, values: null, scheme: Uri.UriSchemeHttps); + } + catch (Exception ex) + { + AcmeInstruments.DirectoryFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + AcmeInstruments.DirectoryDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + } } } diff --git a/src/opencertserver.acme.server/Endpoints/NonceEndpoints.cs b/src/opencertserver.acme.server/Endpoints/NonceEndpoints.cs index 312d75b..fe4ae43 100644 --- a/src/opencertserver.acme.server/Endpoints/NonceEndpoints.cs +++ b/src/opencertserver.acme.server/Endpoints/NonceEndpoints.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Acme.Server.Endpoints; +using System.Diagnostics; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -13,18 +14,53 @@ public static IEndpointRouteBuilder MapNonceEndpoints(this IEndpointRouteBuilder // HEAD /new-nonce endpoints.MapMethods("/new-nonce", ["HEAD"], async (HttpContext context, INonceService nonceService, ILogger logger) => { - await EnsureReplayNonceHeaderAsync(context, nonceService, logger).ConfigureAwait(false); - context.Response.StatusCode = StatusCodes.Status200OK; + AcmeInstruments.NewNonceRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.NewNonce); + try + { + await EnsureReplayNonceHeaderAsync(context, nonceService, logger).ConfigureAwait(false); + context.Response.StatusCode = StatusCodes.Status200OK; + AcmeInstruments.NewNonceSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + AcmeInstruments.NewNonceFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + AcmeInstruments.NewNonceDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + } }) .WithName("NewNonce"); // GET /new-nonce endpoints.MapGet("/new-nonce", async (HttpContext context, INonceService nonceService, ILogger logger) => { - await EnsureReplayNonceHeaderAsync(context, nonceService, logger).ConfigureAwait(false); - context.Response.StatusCode = StatusCodes.Status204NoContent; + AcmeInstruments.NewNonceRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.NewNonce); + try + { + await EnsureReplayNonceHeaderAsync(context, nonceService, logger).ConfigureAwait(false); + context.Response.StatusCode = StatusCodes.Status204NoContent; + AcmeInstruments.NewNonceSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + AcmeInstruments.NewNonceFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + AcmeInstruments.NewNonceDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + } }); -// .WithName("NewNonce"); return endpoints; } diff --git a/src/opencertserver.acme.server/Endpoints/OrderEndpoints.cs b/src/opencertserver.acme.server/Endpoints/OrderEndpoints.cs index 484d26f..2b13d02 100644 --- a/src/opencertserver.acme.server/Endpoints/OrderEndpoints.cs +++ b/src/opencertserver.acme.server/Endpoints/OrderEndpoints.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Acme.Server.Endpoints; +using System.Diagnostics; using CertesSlim.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -26,6 +27,11 @@ public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder IOptions optionsAccessor, CancellationToken cancellationToken) => { + AcmeInstruments.NewOrderRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.NewOrder); + try + { var header = payload.ToAcmeHeader(); var account = await accountService.FromRequest(header, cancellationToken).ConfigureAwait(false); @@ -71,7 +77,18 @@ public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder new RouteValueDictionary([KeyValuePair.Create("orderId", order.OrderId)]), scheme: Uri.UriSchemeHttps) ?? string.Empty; + AcmeInstruments.NewOrderSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + AcmeInstruments.NewOrderDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); return Results.Created(orderUrl, orderResponse); + } + catch (Exception ex) + { + AcmeInstruments.NewOrderFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + AcmeInstruments.NewOrderDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + throw; + } }).WithName("NewOrder"); endpoints.MapPost("/order/{orderId}", async ( @@ -194,19 +211,35 @@ public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder LinkGenerator links, CancellationToken cancellationToken) => { - var account = await accountService.FromRequest(payload.ToAcmeHeader(), cancellationToken).ConfigureAwait(false); - var orderRequest = payload.ToPayload(); - if (string.IsNullOrWhiteSpace(orderRequest?.Csr)) + AcmeInstruments.OrderFinalizeRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.OrderFinalize); + try { - throw new MalformedRequestException("CSR was empty or could not be read."); - } + var account = await accountService.FromRequest(payload.ToAcmeHeader(), cancellationToken).ConfigureAwait(false); + var orderRequest = payload.ToPayload(); + if (string.IsNullOrWhiteSpace(orderRequest?.Csr)) + { + throw new MalformedRequestException("CSR was empty or could not be read."); + } - var order = await orderService.ProcessCsr(account, orderId, orderRequest.Csr, cancellationToken).ConfigureAwait(false); - GetOrderUrls(context, links, order, out var authorizationUrls, out var finalizeUrl, out var certificateUrl); - var orderResponse = - new OpenCertServer.Acme.Abstractions.HttpModel.Order(order, authorizationUrls, finalizeUrl, - certificateUrl); - return Results.Ok(orderResponse); + var order = await orderService.ProcessCsr(account, orderId, orderRequest.Csr, cancellationToken).ConfigureAwait(false); + GetOrderUrls(context, links, order, out var authorizationUrls, out var finalizeUrl, out var certificateUrl); + var orderResponse = + new OpenCertServer.Acme.Abstractions.HttpModel.Order(order, authorizationUrls, finalizeUrl, + certificateUrl); + AcmeInstruments.OrderFinalizeSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + AcmeInstruments.OrderFinalizeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return Results.Ok(orderResponse); + } + catch (Exception ex) + { + AcmeInstruments.OrderFinalizeFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + AcmeInstruments.OrderFinalizeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + throw; + } }).WithName("FinalizeOrder").AddEndpointFilter(); endpoints.MapPost("/order/{orderId}/certificate", [AcmeLocation("GetOrder")] async ( @@ -216,9 +249,25 @@ public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder IAccountService accountService, CancellationToken cancellationToken) => { - var account = await accountService.FromRequest(payload.ToAcmeHeader(), cancellationToken).ConfigureAwait(false); - var certificateChainBytes = await orderService.GetCertificate(account, orderId, cancellationToken).ConfigureAwait(false); - return Results.File(certificateChainBytes, "application/pem-certificate-chain"); + AcmeInstruments.CertificateRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.Certificate); + try + { + var account = await accountService.FromRequest(payload.ToAcmeHeader(), cancellationToken).ConfigureAwait(false); + var certificateChainBytes = await orderService.GetCertificate(account, orderId, cancellationToken).ConfigureAwait(false); + AcmeInstruments.CertificateSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + AcmeInstruments.CertificateDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return Results.File(certificateChainBytes, "application/pem-certificate-chain"); + } + catch (Exception ex) + { + AcmeInstruments.CertificateFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + AcmeInstruments.CertificateDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + throw; + } }).WithName("GetCertificate").AddEndpointFilter(); return endpoints; diff --git a/src/opencertserver.acme.server/Endpoints/RevocationEndpoints.cs b/src/opencertserver.acme.server/Endpoints/RevocationEndpoints.cs index c4d9501..5be4da7 100644 --- a/src/opencertserver.acme.server/Endpoints/RevocationEndpoints.cs +++ b/src/opencertserver.acme.server/Endpoints/RevocationEndpoints.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Acme.Server.Endpoints; +using System.Diagnostics; using CertesSlim.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -18,18 +19,32 @@ public static IEndpointRouteBuilder MapRevocationEndpoints(this IEndpointRouteBu IRevocationService revocationService, CancellationToken cancellationToken) => { - var request = payload.ToPayload(); - if (request == null) + AcmeInstruments.RevokeRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.Revoke); + try { - throw new MalformedRequestException("The revocation request payload was empty or could not be read."); - } + var request = payload.ToPayload(); + if (request == null) + { + throw new MalformedRequestException("The revocation request payload was empty or could not be read."); + } - await revocationService.RevokeCertificate(payload.ToAcmeHeader(), request, cancellationToken).ConfigureAwait(false); - return Results.Ok(); + await revocationService.RevokeCertificate(payload.ToAcmeHeader(), request, cancellationToken).ConfigureAwait(false); + AcmeInstruments.RevokeSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + AcmeInstruments.RevokeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return Results.Ok(); + } + catch (Exception ex) + { + AcmeInstruments.RevokeFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + AcmeInstruments.RevokeDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + throw; + } }).WithName("RevokeCert"); return endpoints; } } - - diff --git a/src/opencertserver.acme.server/MetricNames.cs b/src/opencertserver.acme.server/MetricNames.cs new file mode 100644 index 0000000..9c66893 --- /dev/null +++ b/src/opencertserver.acme.server/MetricNames.cs @@ -0,0 +1,66 @@ +namespace OpenCertServer.Acme.Server; + +/// +/// Centralised metric name constants for all ACME OpenTelemetry instruments. +/// All metric instrument creation MUST reference these constants. +/// +internal static class MetricNames +{ + internal const string MeterName = "opencertserver.acme"; + + // /directory + internal const string DirectoryRequests = "opencertserver.acme.directory.requests"; + internal const string DirectorySuccesses = "opencertserver.acme.directory.successes"; + internal const string DirectoryFailures = "opencertserver.acme.directory.failures"; + internal const string DirectoryDuration = "opencertserver.acme.directory.duration"; + + // /new-nonce + internal const string NewNonceRequests = "opencertserver.acme.newnonce.requests"; + internal const string NewNonceSuccesses = "opencertserver.acme.newnonce.successes"; + internal const string NewNonceFailures = "opencertserver.acme.newnonce.failures"; + internal const string NewNonceDuration = "opencertserver.acme.newnonce.duration"; + + // /new-account + internal const string NewAccountRequests = "opencertserver.acme.newaccount.requests"; + internal const string NewAccountSuccesses = "opencertserver.acme.newaccount.successes"; + internal const string NewAccountFailures = "opencertserver.acme.newaccount.failures"; + internal const string NewAccountDuration = "opencertserver.acme.newaccount.duration"; + + // /new-order + internal const string NewOrderRequests = "opencertserver.acme.neworder.requests"; + internal const string NewOrderSuccesses = "opencertserver.acme.neworder.successes"; + internal const string NewOrderFailures = "opencertserver.acme.neworder.failures"; + internal const string NewOrderDuration = "opencertserver.acme.neworder.duration"; + + // /order/{id}/finalize + internal const string OrderFinalizeRequests = "opencertserver.acme.orderfinalize.requests"; + internal const string OrderFinalizeSuccesses = "opencertserver.acme.orderfinalize.successes"; + internal const string OrderFinalizeFailures = "opencertserver.acme.orderfinalize.failures"; + internal const string OrderFinalizeDuration = "opencertserver.acme.orderfinalize.duration"; + + // /order/{id}/certificate + internal const string CertificateRequests = "opencertserver.acme.certificate.requests"; + internal const string CertificateSuccesses = "opencertserver.acme.certificate.successes"; + internal const string CertificateFailures = "opencertserver.acme.certificate.failures"; + internal const string CertificateDuration = "opencertserver.acme.certificate.duration"; + + // Challenge validation (ValidationWorker) + internal const string ChallengeValidationRequests = "opencertserver.acme.challengevalidation.requests"; + internal const string ChallengeValidationSuccesses = "opencertserver.acme.challengevalidation.successes"; + internal const string ChallengeValidationFailures = "opencertserver.acme.challengevalidation.failures"; + internal const string ChallengeValidationDuration = "opencertserver.acme.challengevalidation.duration"; + internal const string ChallengeValidationActive = "opencertserver.acme.challengevalidation.active"; + + // /key-change + internal const string KeyChangeRequests = "opencertserver.acme.keychange.requests"; + internal const string KeyChangeSuccesses = "opencertserver.acme.keychange.successes"; + internal const string KeyChangeFailures = "opencertserver.acme.keychange.failures"; + internal const string KeyChangeDuration = "opencertserver.acme.keychange.duration"; + + // /revoke-cert + internal const string RevokeRequests = "opencertserver.acme.revoke.requests"; + internal const string RevokeSuccesses = "opencertserver.acme.revoke.successes"; + internal const string RevokeFailures = "opencertserver.acme.revoke.failures"; + internal const string RevokeDuration = "opencertserver.acme.revoke.duration"; +} + diff --git a/src/opencertserver.acme.server/TagKeys.cs b/src/opencertserver.acme.server/TagKeys.cs new file mode 100644 index 0000000..c6bc89e --- /dev/null +++ b/src/opencertserver.acme.server/TagKeys.cs @@ -0,0 +1,24 @@ +namespace OpenCertServer.Acme.Server; + +/// +/// Centralised tag (attribute) key constants for all ACME OpenTelemetry measurements. +/// Use these keys when adding dimensions to counters, histograms and spans. +/// +internal static class TagKeys +{ + /// ACME challenge type, e.g. "http-01", "dns-01", "tls-alpn-01". + internal const string ChallengeType = "acme.challenge.type"; + + /// ACME order identifier type, e.g. "dns". + internal const string IdentifierType = "acme.identifier.type"; + + /// + /// Structured error type; set to the exception class name or ACME error type string + /// on all failure counter and span recordings. + /// + internal const string ErrorType = "error.type"; + + /// HTTP response status code recorded on non-2xx result paths. + internal const string HttpStatusCode = "http.response.status_code"; +} + diff --git a/src/opencertserver.acme.server/Workers/ValidationWorker.cs b/src/opencertserver.acme.server/Workers/ValidationWorker.cs index 3c4a4f3..51ddd35 100644 --- a/src/opencertserver.acme.server/Workers/ValidationWorker.cs +++ b/src/opencertserver.acme.server/Workers/ValidationWorker.cs @@ -3,6 +3,7 @@ namespace OpenCertServer.Acme.Server.Workers; using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Abstractions.Model; @@ -66,27 +67,49 @@ private async Task ValidateOrder(Order order, CancellationToken cancellationToke var challenge = pendingAuthZ.Challenges[0]; - var validator = _challengeValidatorFactory.GetValidator(challenge); - var (isValid, error) = await validator.ValidateChallenge(challenge, account, cancellationToken).ConfigureAwait(false); - - if (isValid) + AcmeInstruments.ChallengeValidationRequests.Add(1); + AcmeInstruments.ChallengeValidationActive.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = AcmeInstruments.ActivitySource.StartActivity(ActivityNames.ChallengeValidation); + try { - challenge.Error = null; - challenge.Validated = DateTimeOffset.UtcNow; - challenge.SetStatus(ChallengeStatus.Valid); - pendingAuthZ.SetStatus(AuthorizationStatus.Valid); + var validator = _challengeValidatorFactory.GetValidator(challenge); + var (isValid, error) = await validator.ValidateChallenge(challenge, account, cancellationToken).ConfigureAwait(false); - if (order.Error != null && string.Equals(order.Error.Type, "urn:ietf:params:acme:error:unauthorized", StringComparison.Ordinal)) + if (isValid) + { + challenge.Error = null; + challenge.Validated = DateTimeOffset.UtcNow; + challenge.SetStatus(ChallengeStatus.Valid); + pendingAuthZ.SetStatus(AuthorizationStatus.Valid); + if (order.Error != null && string.Equals(order.Error.Type, "urn:ietf:params:acme:error:unauthorized", StringComparison.Ordinal)) + { + order.Error = null; + } + + AcmeInstruments.ChallengeValidationSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + } + else { - order.Error = null; + challenge.Error = error ?? new AcmeError("serverInternal", "Challenge validation failed.", pendingAuthZ.Identifier); + challenge.SetStatus(ChallengeStatus.Invalid); + pendingAuthZ.SetStatus(AuthorizationStatus.Invalid); + order.Error = challenge.Error; + AcmeInstruments.ChallengeValidationFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error); } } - else + catch (Exception ex) + { + AcmeInstruments.ChallengeValidationFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally { - challenge.Error = error ?? new AcmeError("serverInternal", "Challenge validation failed.", pendingAuthZ.Identifier); - challenge.SetStatus(ChallengeStatus.Invalid); - pendingAuthZ.SetStatus(AuthorizationStatus.Invalid); - order.Error = challenge.Error; + AcmeInstruments.ChallengeValidationActive.Add(-1); + AcmeInstruments.ChallengeValidationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); } } diff --git a/src/opencertserver.ca.server/ActivityNames.cs b/src/opencertserver.ca.server/ActivityNames.cs new file mode 100644 index 0000000..686df5d --- /dev/null +++ b/src/opencertserver.ca.server/ActivityNames.cs @@ -0,0 +1,16 @@ +namespace OpenCertServer.Ca.Server; + +/// +/// Centralised activity (span) name constants for all CA/OCSP/CRL OpenTelemetry traces. +/// All spans MUST reference these constants; never use inline string literals for activity names. +/// +internal static class ActivityNames +{ + internal const string OcspRequest = "opencertserver.ocsp.request"; + internal const string CrlRequest = "opencertserver.crl.request"; + internal const string CrlGeneration = "opencertserver.crl.generation"; + internal const string CsrSign = "opencertserver.ca.csr"; + internal const string Revoke = "opencertserver.ca.revoke"; + internal const string Inventory = "opencertserver.ca.inventory"; +} + diff --git a/src/opencertserver.ca.server/CaInstruments.cs b/src/opencertserver.ca.server/CaInstruments.cs new file mode 100644 index 0000000..690ddae --- /dev/null +++ b/src/opencertserver.ca.server/CaInstruments.cs @@ -0,0 +1,49 @@ +namespace OpenCertServer.Ca.Server; + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +/// OpenTelemetry instruments for CA, OCSP, and CRL request handlers (RFC 6960, RFC 5280). +internal static class CaInstruments +{ + private static readonly Meter Meter = new(MetricNames.MeterName, "1.0.0"); + + internal static readonly ActivitySource ActivitySource = new(MetricNames.MeterName, "1.0.0"); + + // OCSP – RFC 6960 + internal static readonly Counter OcspRequests = Meter.CreateCounter (MetricNames.OcspRequests, description: "Total OCSP requests"); + internal static readonly Counter OcspSuccesses = Meter.CreateCounter (MetricNames.OcspSuccesses, description: "Successful OCSP responses"); + internal static readonly Counter OcspFailures = Meter.CreateCounter (MetricNames.OcspFailures, description: "Failed OCSP responses"); + internal static readonly Histogram OcspDuration = Meter.CreateHistogram (MetricNames.OcspDuration, "s", "Duration of OCSP request processing"); + + // CRL requests – RFC 5280 + internal static readonly Counter CrlRequests = Meter.CreateCounter (MetricNames.CrlRequests, description: "Total CRL requests"); + internal static readonly Counter CrlSuccesses = Meter.CreateCounter (MetricNames.CrlSuccesses, description: "Successful CRL responses"); + internal static readonly Counter CrlFailures = Meter.CreateCounter (MetricNames.CrlFailures, description: "Failed CRL responses"); + internal static readonly Histogram CrlDuration = Meter.CreateHistogram (MetricNames.CrlDuration, "s", "Duration of CRL requests"); + + // CRL generation + internal static readonly Counter CrlGenerationRequests = Meter.CreateCounter (MetricNames.CrlGenerationRequests, description: "Total CRL generation requests"); + internal static readonly Counter CrlGenerationSuccesses = Meter.CreateCounter (MetricNames.CrlGenerationSuccesses, description: "Successful CRL generations"); + internal static readonly Counter CrlGenerationFailures = Meter.CreateCounter (MetricNames.CrlGenerationFailures, description: "Failed CRL generations"); + internal static readonly Histogram CrlGenerationDuration = Meter.CreateHistogram (MetricNames.CrlGenerationDuration, "s", "Duration of CRL generation"); + + // /ca/csr + internal static readonly Counter CsrRequests = Meter.CreateCounter (MetricNames.CsrRequests, description: "Total CSR signing requests"); + internal static readonly Counter CsrSuccesses = Meter.CreateCounter (MetricNames.CsrSuccesses, description: "Successful CSR signings"); + internal static readonly Counter CsrFailures = Meter.CreateCounter (MetricNames.CsrFailures, description: "Failed CSR signings"); + internal static readonly Histogram CsrDuration = Meter.CreateHistogram (MetricNames.CsrDuration, "s", "Duration of CSR signing"); + + // /ca/revoke + internal static readonly Counter RevocationRequests = Meter.CreateCounter (MetricNames.RevocationRequests, description: "Total revocation requests"); + internal static readonly Counter RevocationSuccesses = Meter.CreateCounter (MetricNames.RevocationSuccesses, description: "Successful revocations"); + internal static readonly Counter RevocationFailures = Meter.CreateCounter (MetricNames.RevocationFailures, description: "Failed revocations"); + internal static readonly Histogram RevocationDuration = Meter.CreateHistogram (MetricNames.RevocationDuration, "s", "Duration of revocation requests"); + + // /ca/inventory + internal static readonly Counter InventoryRequests = Meter.CreateCounter (MetricNames.InventoryRequests, description: "Total inventory requests"); + internal static readonly Counter InventorySuccesses = Meter.CreateCounter (MetricNames.InventorySuccesses, description: "Successful inventory responses"); + internal static readonly Counter InventoryFailures = Meter.CreateCounter (MetricNames.InventoryFailures, description: "Failed inventory responses"); + internal static readonly Histogram InventoryDuration = Meter.CreateHistogram (MetricNames.InventoryDuration, "s", "Duration of inventory requests"); +} + diff --git a/src/opencertserver.ca.server/Handlers/CrlHandler.cs b/src/opencertserver.ca.server/Handlers/CrlHandler.cs index 3761f7a..30ab49a 100644 --- a/src/opencertserver.ca.server/Handlers/CrlHandler.cs +++ b/src/opencertserver.ca.server/Handlers/CrlHandler.cs @@ -2,6 +2,7 @@ namespace OpenCertServer.Ca.Server.Handlers; +using System.Diagnostics; using Microsoft.AspNetCore.Http; using OpenCertServer.Ca.Utils.Ca; @@ -14,7 +15,30 @@ public static Task Handle(ICertificateAuthority ca) public static async Task HandleProfile([FromRoute] string profileName, ICertificateAuthority ca) { - var crl = await ca.GetRevocationList(profileName).ConfigureAwait(false); - return Results.Bytes(crl, "application/pkix-crl"); + CaInstruments.CrlRequests.Add(1); + CaInstruments.CrlGenerationRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.CrlRequest); + try + { + var crl = await ca.GetRevocationList(profileName).ConfigureAwait(false); + CaInstruments.CrlSuccesses.Add(1); + CaInstruments.CrlGenerationSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + return Results.Bytes(crl, "application/pkix-crl"); + } + catch (Exception ex) + { + CaInstruments.CrlFailures.Add(1); + CaInstruments.CrlGenerationFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + var elapsed = Stopwatch.GetElapsedTime(sw).TotalSeconds; + CaInstruments.CrlDuration.Record(elapsed); + CaInstruments.CrlGenerationDuration.Record(elapsed); + } } } diff --git a/src/opencertserver.ca.server/Handlers/CsrHandler.cs b/src/opencertserver.ca.server/Handlers/CsrHandler.cs index 8a720f4..855e903 100644 --- a/src/opencertserver.ca.server/Handlers/CsrHandler.cs +++ b/src/opencertserver.ca.server/Handlers/CsrHandler.cs @@ -4,6 +4,7 @@ namespace OpenCertServer.Ca.Server.Handlers; +using System.Diagnostics; using System.Net; using Microsoft.AspNetCore.Http; using OpenCertServer.Ca.Utils.Ca; @@ -18,20 +19,40 @@ public static async Task Handle( [FromBody] Stream body, CancellationToken cancellationToken) { - using var reader = new StreamReader(body); - var csrPem = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - var certResponse = await ca.SignCertificateRequestPem(csrPem, profileName, user.Identity as ClaimsIdentity, - cancellationToken: cancellationToken); - if (certResponse is SignCertificateResponse.Success success) + CaInstruments.CsrRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.CsrSign); + try { - return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemMimeType); - } + using var reader = new StreamReader(body); + var csrPem = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var certResponse = await ca.SignCertificateRequestPem(csrPem, profileName, user.Identity as ClaimsIdentity, + cancellationToken: cancellationToken); + if (certResponse is SignCertificateResponse.Success success) + { + CaInstruments.CsrSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemMimeType); + } - var error = (SignCertificateResponse.Error)certResponse; - return Results.Text( - string.Join(Environment.NewLine, error.Errors), - Constants.TextPlainMimeType, - Encoding.UTF8, - (int)HttpStatusCode.BadRequest); + var error = (SignCertificateResponse.Error)certResponse; + CaInstruments.CsrFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error); + return Results.Text( + string.Join(Environment.NewLine, error.Errors), + Constants.TextPlainMimeType, + Encoding.UTF8, + (int)HttpStatusCode.BadRequest); + } + catch (Exception ex) + { + CaInstruments.CsrFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + CaInstruments.CsrDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + } } } diff --git a/src/opencertserver.ca.server/Handlers/InventoryHandler.cs b/src/opencertserver.ca.server/Handlers/InventoryHandler.cs index 7b9e2fa..f02bed3 100644 --- a/src/opencertserver.ca.server/Handlers/InventoryHandler.cs +++ b/src/opencertserver.ca.server/Handlers/InventoryHandler.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Ca.Server.Handlers; +using System.Diagnostics; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -9,14 +10,33 @@ public static class InventoryHandler { public static async Task Handle(HttpContext context) { - var store = context.RequestServices.GetRequiredService(); - var page = context.Request.Query.ContainsKey("page") - ? int.TryParse(context.Request.Query["page"], out var p) ? p : 0 - : 0; - var inventory = await store.GetInventory(page).ToArrayAsync().ConfigureAwait(false); - context.Response.ContentType = "application/json"; - await JsonSerializer.SerializeAsync(context.Response.Body, inventory, - CaServerSerializerContext.Default.CertificateItemInfoArray).ConfigureAwait(false); - await context.Response.Body.FlushAsync().ConfigureAwait(false); + CaInstruments.InventoryRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.Inventory); + try + { + var store = context.RequestServices.GetRequiredService(); + var page = context.Request.Query.ContainsKey("page") + ? int.TryParse(context.Request.Query["page"], out var p) ? p : 0 + : 0; + var inventory = await store.GetInventory(page).ToArrayAsync().ConfigureAwait(false); + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, inventory, + CaServerSerializerContext.Default.CertificateItemInfoArray).ConfigureAwait(false); + await context.Response.Body.FlushAsync().ConfigureAwait(false); + CaInstruments.InventorySuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + CaInstruments.InventoryFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + CaInstruments.InventoryDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + } } + } diff --git a/src/opencertserver.ca.server/Handlers/OcspHandler.cs b/src/opencertserver.ca.server/Handlers/OcspHandler.cs index 63003fa..bb9d815 100644 --- a/src/opencertserver.ca.server/Handlers/OcspHandler.cs +++ b/src/opencertserver.ca.server/Handlers/OcspHandler.cs @@ -1,6 +1,6 @@ namespace OpenCertServer.Ca.Server.Handlers; -using System.Buffers.Text; +using System.Diagnostics; using System.Formats.Asn1; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -15,6 +15,9 @@ public static class OcspHandler { public static async Task Handle(HttpContext context) { + CaInstruments.OcspRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.OcspRequest); var cancellationToken = context.RequestAborted; byte[] requestBytes; @@ -25,6 +28,9 @@ public static async Task Handle(HttpContext context) StringComparison.OrdinalIgnoreCase)) { context.Response.StatusCode = StatusCodes.Status400BadRequest; + CaInstruments.OcspFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, "Invalid content type"); + CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); return; } @@ -35,6 +41,9 @@ public static async Task Handle(HttpContext context) else { context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + CaInstruments.OcspFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, "Method not allowed"); + CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); return; } @@ -43,22 +52,31 @@ public static async Task Handle(HttpContext context) response.ContentType = "application/ocsp-response"; await response.Body.WriteAsync(responseBytes, cancellationToken).ConfigureAwait(false); await response.CompleteAsync().ConfigureAwait(false); + CaInstruments.OcspSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); } public static async Task HandleGet(HttpContext context) { + CaInstruments.OcspRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.OcspRequest); var cancellationToken = context.RequestAborted; var encodedRequest = context.Request.RouteValues["requestEncoded"] as string; if (string.IsNullOrWhiteSpace(encodedRequest)) { context.Response.StatusCode = StatusCodes.Status400BadRequest; + CaInstruments.OcspFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, "Missing encoded request"); + CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); return; } byte[] requestBytes; try { - requestBytes = Base64Url.DecodeFromChars(encodedRequest.AsSpan()); + requestBytes = System.Buffers.Text.Base64Url.DecodeFromChars(encodedRequest.AsSpan()); } catch { @@ -68,6 +86,9 @@ public static async Task HandleGet(HttpContext context) context.Response.ContentType = "application/ocsp-response"; await context.Response.Body.WriteAsync(w.Encode(), cancellationToken).ConfigureAwait(false); await context.Response.CompleteAsync().ConfigureAwait(false); + CaInstruments.OcspFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, "Malformed request"); + CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); return; } @@ -76,6 +97,9 @@ public static async Task HandleGet(HttpContext context) response.ContentType = "application/ocsp-response"; await response.Body.WriteAsync(responseBytes, cancellationToken).ConfigureAwait(false); await response.CompleteAsync().ConfigureAwait(false); + CaInstruments.OcspSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + CaInstruments.OcspDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); } private static async Task ProcessRequestAsync( diff --git a/src/opencertserver.ca.server/Handlers/RevocationHandler.cs b/src/opencertserver.ca.server/Handlers/RevocationHandler.cs index a7ab2c8..7d5fe4f 100644 --- a/src/opencertserver.ca.server/Handlers/RevocationHandler.cs +++ b/src/opencertserver.ca.server/Handlers/RevocationHandler.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Ca.Server.Handlers; +using System.Diagnostics; using System.Net; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -13,40 +14,76 @@ public static class RevocationHandler { public static async Task Handle(HttpContext context) { - var clientCert = await context.Connection.GetClientCertificateAsync().ConfigureAwait(false); - if (clientCert == null) + CaInstruments.RevocationRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.Revoke); + try { - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + var clientCert = await context.Connection.GetClientCertificateAsync().ConfigureAwait(false); + if (clientCert == null) + { + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + await context.Response.CompleteAsync().ConfigureAwait(false); + CaInstruments.RevocationFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, "No client certificate"); + CaInstruments.RevocationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return; + } + + var signature = context.Request.Query["signature"].ToString().Base64DecodeBytes(); + var serialNumberHex = context.Request.Query["sn"].ToString(); + var asymmetricAlgorithm = clientCert.GetRSAPublicKey() ?? (AsymmetricAlgorithm?)clientCert.GetECDsaPublicKey(); + var reasonString = context.Request.Query["reason"]; + if (asymmetricAlgorithm == null + || !asymmetricAlgorithm.VerifySignature( + Encoding.UTF8.GetBytes(serialNumberHex + reasonString), + signature, + HashAlgorithmName.SHA256)) + { + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + await context.Response.CompleteAsync().ConfigureAwait(false); + CaInstruments.RevocationFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, "Signature verification failed"); + CaInstruments.RevocationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return; + } + + if (string.IsNullOrEmpty(serialNumberHex) + || !Enum.TryParse(reasonString.ToString(), true, out X509RevocationReason reason)) + { + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + await context.Response.CompleteAsync().ConfigureAwait(false); + CaInstruments.RevocationFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, "Invalid parameters"); + CaInstruments.RevocationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return; + } + + var ca = context.RequestServices.GetRequiredService(); + var result = await ca.RevokeCertificate(serialNumberHex, reason).ConfigureAwait(false); + context.Response.StatusCode = result ? (int)HttpStatusCode.OK : (int)HttpStatusCode.NotFound; await context.Response.CompleteAsync().ConfigureAwait(false); - return; + + if (result) + { + CaInstruments.RevocationSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + } + else + { + CaInstruments.RevocationFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, "Certificate not found"); + } } - var signature = context.Request.Query["signature"].ToString().Base64DecodeBytes(); - var serialNumberHex = context.Request.Query["sn"].ToString(); - var asymmetricAlgorithm = clientCert.GetRSAPublicKey() ?? (AsymmetricAlgorithm?)clientCert.GetECDsaPublicKey(); - var reasonString = context.Request.Query["reason"]; - if (asymmetricAlgorithm == null - || !asymmetricAlgorithm.VerifySignature( - Encoding.UTF8.GetBytes(serialNumberHex + reasonString), - signature, - HashAlgorithmName.SHA256)) + catch (Exception ex) { - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - await context.Response.CompleteAsync().ConfigureAwait(false); - return; + CaInstruments.RevocationFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; } - - if (string.IsNullOrEmpty(serialNumberHex) - || !Enum.TryParse(reasonString.ToString(), true, - out X509RevocationReason reason)) + finally { - context.Response.StatusCode = (int)HttpStatusCode.BadRequest; - await context.Response.CompleteAsync().ConfigureAwait(false); - return; + CaInstruments.RevocationDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); } - - var ca = context.RequestServices.GetRequiredService(); - var result = await ca.RevokeCertificate(serialNumberHex, reason).ConfigureAwait(false); - context.Response.StatusCode = result ? (int)HttpStatusCode.OK : (int)HttpStatusCode.NotFound; - await context.Response.CompleteAsync().ConfigureAwait(false); } } diff --git a/src/opencertserver.ca.server/MetricNames.cs b/src/opencertserver.ca.server/MetricNames.cs new file mode 100644 index 0000000..8c23407 --- /dev/null +++ b/src/opencertserver.ca.server/MetricNames.cs @@ -0,0 +1,47 @@ +namespace OpenCertServer.Ca.Server; + +/// +/// Centralised metric name constants for all CA/OCSP/CRL OpenTelemetry instruments. +/// All metric instrument creation MUST reference these constants. +/// +internal static class MetricNames +{ + internal const string MeterName = "opencertserver.ca"; + + // OCSP – RFC 6960 + internal const string OcspRequests = "opencertserver.ocsp.request.requests"; + internal const string OcspSuccesses = "opencertserver.ocsp.request.successes"; + internal const string OcspFailures = "opencertserver.ocsp.request.failures"; + internal const string OcspDuration = "opencertserver.ocsp.request.duration"; + + // CRL requests – RFC 5280 + internal const string CrlRequests = "opencertserver.crl.request.requests"; + internal const string CrlSuccesses = "opencertserver.crl.request.successes"; + internal const string CrlFailures = "opencertserver.crl.request.failures"; + internal const string CrlDuration = "opencertserver.crl.request.duration"; + + // CRL generation + internal const string CrlGenerationRequests = "opencertserver.crl.generation.requests"; + internal const string CrlGenerationSuccesses = "opencertserver.crl.generation.successes"; + internal const string CrlGenerationFailures = "opencertserver.crl.generation.failures"; + internal const string CrlGenerationDuration = "opencertserver.crl.generation.duration"; + + // /ca/csr (CSR signing) + internal const string CsrRequests = "opencertserver.ca.csr.requests"; + internal const string CsrSuccesses = "opencertserver.ca.csr.successes"; + internal const string CsrFailures = "opencertserver.ca.csr.failures"; + internal const string CsrDuration = "opencertserver.ca.csr.duration"; + + // /ca/revoke + internal const string RevocationRequests = "opencertserver.ca.revoke.requests"; + internal const string RevocationSuccesses = "opencertserver.ca.revoke.successes"; + internal const string RevocationFailures = "opencertserver.ca.revoke.failures"; + internal const string RevocationDuration = "opencertserver.ca.revoke.duration"; + + // /ca/inventory + internal const string InventoryRequests = "opencertserver.ca.inventory.requests"; + internal const string InventorySuccesses = "opencertserver.ca.inventory.successes"; + internal const string InventoryFailures = "opencertserver.ca.inventory.failures"; + internal const string InventoryDuration = "opencertserver.ca.inventory.duration"; +} + diff --git a/src/opencertserver.ca.server/TagKeys.cs b/src/opencertserver.ca.server/TagKeys.cs new file mode 100644 index 0000000..6cb66e4 --- /dev/null +++ b/src/opencertserver.ca.server/TagKeys.cs @@ -0,0 +1,27 @@ +namespace OpenCertServer.Ca.Server; + +/// +/// Centralised tag (attribute) key constants for all CA/OCSP/CRL OpenTelemetry measurements. +/// Use these keys when adding dimensions to counters, histograms and spans. +/// +internal static class TagKeys +{ + /// CA profile name (empty string for the default profile). + internal const string Profile = "ca.profile"; + + /// OCSP response status, e.g. "good", "revoked", "unknown". + internal const string OcspStatus = "ocsp.status"; + + /// X.509 revocation reason code. + internal const string RevocationReason = "ca.revocation.reason"; + + /// + /// Structured error type; set to the exception class name or a short error code + /// on all failure counter and span recordings. + /// + internal const string ErrorType = "error.type"; + + /// HTTP response status code recorded on non-2xx result paths. + internal const string HttpStatusCode = "http.response.status_code"; +} + diff --git a/src/opencertserver.est.server/ActivityNames.cs b/src/opencertserver.est.server/ActivityNames.cs new file mode 100644 index 0000000..9b726f8 --- /dev/null +++ b/src/opencertserver.est.server/ActivityNames.cs @@ -0,0 +1,15 @@ +namespace OpenCertServer.Est.Server; + +/// +/// Centralised activity (span) name constants for all EST OpenTelemetry traces. +/// All spans MUST reference these constants; never use inline string literals for activity names. +/// +internal static class ActivityNames +{ + internal const string CaCerts = "opencertserver.est.cacerts"; + internal const string SimpleEnroll = "opencertserver.est.simpleenroll"; + internal const string SimpleReEnroll = "opencertserver.est.simplereenroll"; + internal const string CsrAttrs = "opencertserver.est.csrattrs"; + internal const string ServerKeyGen = "opencertserver.est.serverkeygen"; +} + diff --git a/src/opencertserver.est.server/EstInstruments.cs b/src/opencertserver.est.server/EstInstruments.cs new file mode 100644 index 0000000..cc39510 --- /dev/null +++ b/src/opencertserver.est.server/EstInstruments.cs @@ -0,0 +1,42 @@ +namespace OpenCertServer.Est.Server; + +using System.Diagnostics; +using System.Diagnostics.Metrics; + +/// OpenTelemetry instruments for all EST request handlers (RFC 7030). +internal static class EstInstruments +{ + private static readonly Meter Meter = new(MetricNames.MeterName, "1.0.0"); + + internal static readonly ActivitySource ActivitySource = new(MetricNames.MeterName, "1.0.0"); + + // /cacerts + internal static readonly Counter CaCertsRequests = Meter.CreateCounter (MetricNames.CaCertsRequests, description: "Total /cacerts requests"); + internal static readonly Counter CaCertsSuccesses = Meter.CreateCounter (MetricNames.CaCertsSuccesses, description: "Successful /cacerts responses"); + internal static readonly Counter CaCertsFailures = Meter.CreateCounter (MetricNames.CaCertsFailures, description: "Failed /cacerts responses"); + internal static readonly Histogram CaCertsDuration = Meter.CreateHistogram (MetricNames.CaCertsDuration, "s", "Duration of /cacerts requests"); + + // /simpleenroll + internal static readonly Counter SimpleEnrollRequests = Meter.CreateCounter (MetricNames.SimpleEnrollRequests, description: "Total /simpleenroll requests"); + internal static readonly Counter SimpleEnrollSuccesses = Meter.CreateCounter (MetricNames.SimpleEnrollSuccesses, description: "Successful /simpleenroll responses"); + internal static readonly Counter SimpleEnrollFailures = Meter.CreateCounter (MetricNames.SimpleEnrollFailures, description: "Failed /simpleenroll responses"); + internal static readonly Histogram SimpleEnrollDuration = Meter.CreateHistogram (MetricNames.SimpleEnrollDuration, "s", "Duration of /simpleenroll requests"); + + // /simplereenroll + internal static readonly Counter SimpleReEnrollRequests = Meter.CreateCounter (MetricNames.SimpleReEnrollRequests, description: "Total /simplereenroll requests"); + internal static readonly Counter SimpleReEnrollSuccesses = Meter.CreateCounter (MetricNames.SimpleReEnrollSuccesses, description: "Successful /simplereenroll responses"); + internal static readonly Counter SimpleReEnrollFailures = Meter.CreateCounter (MetricNames.SimpleReEnrollFailures, description: "Failed /simplereenroll responses"); + internal static readonly Histogram SimpleReEnrollDuration = Meter.CreateHistogram (MetricNames.SimpleReEnrollDuration, "s", "Duration of /simplereenroll requests"); + + // /csrattrs + internal static readonly Counter CsrAttrsRequests = Meter.CreateCounter (MetricNames.CsrAttrsRequests, description: "Total /csrattrs requests"); + internal static readonly Counter CsrAttrsSuccesses = Meter.CreateCounter (MetricNames.CsrAttrsSuccesses, description: "Successful /csrattrs responses"); + internal static readonly Counter CsrAttrsFailures = Meter.CreateCounter (MetricNames.CsrAttrsFailures, description: "Failed /csrattrs responses"); + internal static readonly Histogram CsrAttrsDuration = Meter.CreateHistogram (MetricNames.CsrAttrsDuration, "s", "Duration of /csrattrs requests"); + + // /serverkeygen + internal static readonly Counter ServerKeyGenRequests = Meter.CreateCounter (MetricNames.ServerKeyGenRequests, description: "Total /serverkeygen requests"); + internal static readonly Counter ServerKeyGenSuccesses = Meter.CreateCounter (MetricNames.ServerKeyGenSuccesses, description: "Successful /serverkeygen responses"); + internal static readonly Counter ServerKeyGenFailures = Meter.CreateCounter (MetricNames.ServerKeyGenFailures, description: "Failed /serverkeygen responses"); + internal static readonly Histogram ServerKeyGenDuration = Meter.CreateHistogram (MetricNames.ServerKeyGenDuration, "s", "Duration of /serverkeygen requests"); +} diff --git a/src/opencertserver.est.server/Handlers/CaCertHandler.cs b/src/opencertserver.est.server/Handlers/CaCertHandler.cs index 781e0b3..8c307ef 100644 --- a/src/opencertserver.est.server/Handlers/CaCertHandler.cs +++ b/src/opencertserver.est.server/Handlers/CaCertHandler.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Est.Server.Handlers; +using System.Diagnostics; using System.Formats.Asn1; using System.Net; using Microsoft.AspNetCore.Http; @@ -21,15 +22,33 @@ public static async Task HandleProfile( EstPublishedCertificatesResolver certificates, CancellationToken cancellationToken = default) { - var export = await certificates(profileName, cancellationToken).ConfigureAwait(false); - var signedData = new SignedData(version: 1, certificates: export.ToArray()); - var contentInfo = new CmsContentInfo( - Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName), - signedData); - var writer = new AsnWriter(AsnEncodingRules.DER); - contentInfo.Encode(writer); - var contentBytes = writer.Encode(); - return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly, - statusCode: (int)HttpStatusCode.OK); + EstInstruments.CaCertsRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.CaCerts); + try + { + var export = await certificates(profileName, cancellationToken).ConfigureAwait(false); + var signedData = new SignedData(version: 1, certificates: export.ToArray()); + var contentInfo = new CmsContentInfo( + Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName), + signedData); + var writer = new AsnWriter(AsnEncodingRules.DER); + contentInfo.Encode(writer); + var contentBytes = writer.Encode(); + EstInstruments.CaCertsSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly, + statusCode: (int)HttpStatusCode.OK); + } + catch (Exception ex) + { + EstInstruments.CaCertsFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + EstInstruments.CaCertsDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + } } } diff --git a/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs b/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs index dc15855..be896da 100644 --- a/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs +++ b/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs @@ -2,6 +2,7 @@ namespace OpenCertServer.Est.Server.Handlers; +using System.Diagnostics; using System.Security.Claims; using Microsoft.AspNetCore.Http; @@ -21,7 +22,25 @@ public static async Task HandleProfile( ICsrTemplateLoader loader, CancellationToken cancellationToken = default) { - var attributes = await loader.GetTemplate(profileName, user, cancellationToken).ConfigureAwait(false); - return new CertificateSigningRequestTemplateResult(attributes); + EstInstruments.CsrAttrsRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.CsrAttrs); + try + { + var attributes = await loader.GetTemplate(profileName, user, cancellationToken).ConfigureAwait(false); + EstInstruments.CsrAttrsSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + return new CertificateSigningRequestTemplateResult(attributes); + } + catch (Exception ex) + { + EstInstruments.CsrAttrsFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + EstInstruments.CsrAttrsDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + } } } diff --git a/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs b/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs index 3fb6433..772cb59 100644 --- a/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs +++ b/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using OpenCertServer.Ca.Utils.Ca; @@ -40,61 +41,95 @@ public static async Task HandleProfile( Stream body, CancellationToken cancellationToken) { + EstInstruments.ServerKeyGenRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.ServerKeyGen); + IResult result; try { - using var reader = new StreamReader(body, Encoding.UTF8); - var request = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - var signingRequest = PemEncoding.TryFind(request, out _) - ? CertificateRequest.LoadSigningRequestPem( - request, - HashAlgorithmName.SHA256) - : CertificateRequest.LoadSigningRequest( - request.Base64DecodeBytes(), - HashAlgorithmName.SHA256, - CertificateRequestLoadOptions.SkipSignatureValidation, - RSASignaturePadding.Pss); - - var encryptedKeyDelivery = GetRequestedEncryptedKeyDelivery(httpRequest); - if (encryptedKeyDelivery.ErrorResult != null) - { - return encryptedKeyDelivery.ErrorResult; - } + result = await Core().ConfigureAwait(false); + } + catch (Exception ex) + { + EstInstruments.ServerKeyGenFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + EstInstruments.ServerKeyGenDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + throw; + } - var privateKey = signingRequest.PublicKey.Oid.Value switch + var statusCode = (result as IStatusCodeHttpResult)?.StatusCode ?? 200; + if (statusCode >= 400) + { + EstInstruments.ServerKeyGenFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error); + } + else + { + EstInstruments.ServerKeyGenSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + } + + EstInstruments.ServerKeyGenDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return result; + + async Task Core() + { + try { - Oids.Rsa => CreateServerSideRsaRequest(signingRequest), - Oids.EcPublicKey => CreateServerSideEcRequest(signingRequest), - _ => throw new NotSupportedException( - $"Server-side key generation does not support CSR public key algorithm '{signingRequest.PublicKey.Oid.Value}'.") - }; - - var newCert = - await certificateAuthority.SignCertificateRequest(privateKey.Request, profileName, - user.Identity as ClaimsIdentity, cancellationToken: cancellationToken).ConfigureAwait(false); - if (newCert is SignCertificateResponse.Success success) + using var reader = new StreamReader(body, Encoding.UTF8); + var request = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + var signingRequest = PemEncoding.TryFind(request, out _) + ? CertificateRequest.LoadSigningRequestPem( + request, + HashAlgorithmName.SHA256) + : CertificateRequest.LoadSigningRequest( + request.Base64DecodeBytes(), + HashAlgorithmName.SHA256, + CertificateRequestLoadOptions.SkipSignatureValidation, + RSASignaturePadding.Pss); + + var encryptedKeyDelivery = GetRequestedEncryptedKeyDelivery(httpRequest); + if (encryptedKeyDelivery.ErrorResult != null) + { + return encryptedKeyDelivery.ErrorResult; + } + + var privateKey = signingRequest.PublicKey.Oid.Value switch + { + Oids.Rsa => CreateServerSideRsaRequest(signingRequest), + Oids.EcPublicKey => CreateServerSideEcRequest(signingRequest), + _ => throw new NotSupportedException( + $"Server-side key generation does not support CSR public key algorithm '{signingRequest.PublicKey.Oid.Value}'.") + }; + + var newCert = + await certificateAuthority.SignCertificateRequest(privateKey.Request, profileName, + user.Identity as ClaimsIdentity, cancellationToken: cancellationToken).ConfigureAwait(false); + if (newCert is SignCertificateResponse.Success success) + { + var mpr = new MultipartContent(); + mpr.Add(encryptedKeyDelivery.UseEncryptedKeyPart + ? new EstMultipartBase64Content( + privateKey.Pkcs8.Base64Encode(), + Constants.PemMimeType, + smimeType: "server-generated-key") + : new EstMultipartBase64Content(privateKey.Pkcs8.Base64Encode(), Constants.Pkcs8)); + mpr.Add(new EstMultipartBase64Content(CreateCertsOnlyResponse(success.Certificate), Constants.PemMimeType)); + return new MultipartContentResult(mpr); + } + + var error = (SignCertificateResponse.Error)newCert; + return Results.Text( + string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType, + Encoding.UTF8, + (int)HttpStatusCode.BadRequest); + } + catch (Exception) { - var mpr = new MultipartContent(); - mpr.Add(encryptedKeyDelivery.UseEncryptedKeyPart - ? new EstMultipartBase64Content( - privateKey.Pkcs8.Base64Encode(), - Constants.PemMimeType, - smimeType: "server-generated-key") - : new EstMultipartBase64Content(privateKey.Pkcs8.Base64Encode(), Constants.Pkcs8)); - mpr.Add(new EstMultipartBase64Content(CreateCertsOnlyResponse(success.Certificate), Constants.PemMimeType)); - return new MultipartContentResult(mpr); + return Results.Text( + "An error occurred while processing the request.", Constants.TextPlainMimeType, Encoding.UTF8, + (int)HttpStatusCode.BadRequest); } - - var error = (SignCertificateResponse.Error)newCert; - return Results.Text( - string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType, - Encoding.UTF8, - (int)HttpStatusCode.BadRequest); - } - catch (Exception) - { - return Results.Text( - "An error occurred while processing the request.", Constants.TextPlainMimeType, Encoding.UTF8, - (int)HttpStatusCode.BadRequest); } } diff --git a/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs b/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs index ae44fed..b09466e 100644 --- a/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs +++ b/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs @@ -2,6 +2,7 @@ namespace OpenCertServer.Est.Server.Handlers; +using System.Diagnostics; using System.Formats.Asn1; using System.IO; using System.Net; @@ -38,84 +39,118 @@ public static async Task HandleProfile( IManualAuthorizationStrategy manualAuthorizationStrategy, CancellationToken cancellationToken) { - var body = httpRequest.Body; - var responseType = httpRequest.GetTypedHeaders().Accept; - using var reader = new StreamReader(body, Encoding.UTF8); - var requestContent = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + EstInstruments.SimpleEnrollRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.SimpleEnroll); + IResult result; try { - requestContent = requestContent.NormalizeBase64(); + result = await Core().ConfigureAwait(false); } - catch (FormatException) + catch (Exception ex) { + EstInstruments.SimpleEnrollFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + EstInstruments.SimpleEnrollDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + throw; } - catch (InvalidOperationException o) + + var statusCode = (result as IStatusCodeHttpResult)?.StatusCode ?? 200; + if (statusCode >= 400) { - return Results.Text(o.Message, Constants.TextPlainMimeType, Encoding.UTF8, - (int)HttpStatusCode.BadRequest); + EstInstruments.SimpleEnrollFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error); } - - if (!requestContent.TryVerifyTlsUniqueValue(out var proofOfPossessionError)) + else { - return Results.Text(proofOfPossessionError, Constants.TextPlainMimeType, Encoding.UTF8, - (int)HttpStatusCode.BadRequest); + EstInstruments.SimpleEnrollSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); } - var csrDer = Convert.FromBase64String(requestContent); - var csr = CertificateRequest.LoadSigningRequest( - csrDer, - HashAlgorithmName.SHA256, - signerSignaturePadding: RSASignaturePadding.Pss); + EstInstruments.SimpleEnrollDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return result; - if (manualAuthorizationStrategy.TryGetPendingAuthorization( - httpRequest, - user, - csr, - out var retryAfter, - out var pendingMessage)) + async Task Core() { - return new RetryAfterResult(retryAfter, pendingMessage); - } + var body = httpRequest.Body; + var responseType = httpRequest.GetTypedHeaders().Accept; + using var reader = new StreamReader(body, Encoding.UTF8); + var requestContent = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + try + { + requestContent = requestContent.NormalizeBase64(); + } + catch (FormatException) + { + } + catch (InvalidOperationException o) + { + return Results.Text(o.Message, Constants.TextPlainMimeType, Encoding.UTF8, + (int)HttpStatusCode.BadRequest); + } - SignCertificateResponse newCert; - try - { - newCert = await certificateAuthority.SignCertificateRequest( + if (!requestContent.TryVerifyTlsUniqueValue(out var proofOfPossessionError)) + { + return Results.Text(proofOfPossessionError, Constants.TextPlainMimeType, Encoding.UTF8, + (int)HttpStatusCode.BadRequest); + } + + var csrDer = Convert.FromBase64String(requestContent); + var csr = CertificateRequest.LoadSigningRequest( + csrDer, + HashAlgorithmName.SHA256, + signerSignaturePadding: RSASignaturePadding.Pss); + + if (manualAuthorizationStrategy.TryGetPendingAuthorization( + httpRequest, + user, csr, - profileName, - user?.Identity as ClaimsIdentity, - cancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - return Results.Text($"The enrollment CSR could not be parsed: {ex.Message}", Constants.TextPlainMimeType, - Encoding.UTF8, - (int)HttpStatusCode.BadRequest); - } + out var retryAfter, + out var pendingMessage)) + { + return new RetryAfterResult(retryAfter, pendingMessage); + } - if (newCert is SignCertificateResponse.Success success) - { - // This is a deviation from the RFC but is easier to parse. - if (responseType.Any(x => - x.MediaType.HasValue && - x.MediaType.Value.Equals(Constants.PemFile, StringComparison.OrdinalIgnoreCase))) + SignCertificateResponse newCert; + try { - return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemFile); + newCert = await certificateAuthority.SignCertificateRequest( + csr, + profileName, + user?.Identity as ClaimsIdentity, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + return Results.Text($"The enrollment CSR could not be parsed: {ex.Message}", Constants.TextPlainMimeType, + Encoding.UTF8, + (int)HttpStatusCode.BadRequest); } - X509Certificate2[] content = [success.Certificate]; - var signedResponse = new SignedData(version: 1, certificates: content); - var contentInfo = new CmsContentInfo( - Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName), - signedResponse); - var writer = new AsnWriter(AsnEncodingRules.DER); - contentInfo.Encode(writer); - var contentBytes = writer.Encode(); - return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly); - } + if (newCert is SignCertificateResponse.Success success) + { + // This is a deviation from the RFC but is easier to parse. + if (responseType.Any(x => + x.MediaType.HasValue && + x.MediaType.Value.Equals(Constants.PemFile, StringComparison.OrdinalIgnoreCase))) + { + return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemFile); + } - var error = (SignCertificateResponse.Error)newCert; - return Results.Text(string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType, Encoding.UTF8, - (int)HttpStatusCode.BadRequest); + X509Certificate2[] content = [success.Certificate]; + var signedResponse = new SignedData(version: 1, certificates: content); + var contentInfo = new CmsContentInfo( + Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName), + signedResponse); + var writer = new AsnWriter(AsnEncodingRules.DER); + contentInfo.Encode(writer); + var contentBytes = writer.Encode(); + return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly); + } + + var error = (SignCertificateResponse.Error)newCert; + return Results.Text(string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType, Encoding.UTF8, + (int)HttpStatusCode.BadRequest); + } } } diff --git a/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs b/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs index 779d2b9..6de0dc8 100644 --- a/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs +++ b/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Est.Server.Handlers; +using System.Diagnostics; using System.Formats.Asn1; using System.IO; using System.Net; @@ -33,94 +34,128 @@ public static async Task HandleProfile( [FromRoute] string profileName, CancellationToken cancellationToken) { - var cert = await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false); - using var reader = new StreamReader(context.Request.Body, Encoding.UTF8); - var requestBody = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - - if (cert == null) - { - return Results.Text("A client certificate is required for simple re-enrollment.", - Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest); - } - - if (!requestBody.TryVerifyTlsUniqueValue(out var proofOfPossessionError)) - { - return Results.Text(proofOfPossessionError, Constants.TextPlainMimeType, Encoding.UTF8, - (int)HttpStatusCode.BadRequest); - } - - CertificateRequest request; + EstInstruments.SimpleReEnrollRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.SimpleReEnroll); + IResult result; try { - request = PemEncoding.TryFind(requestBody, out _) - ? CertificateRequest.LoadSigningRequestPem( - requestBody, - HashAlgorithmName.SHA256, - CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions, - RSASignaturePadding.Pss) - : CertificateRequest.LoadSigningRequest( - requestBody.Base64DecodeBytes(), - HashAlgorithmName.SHA256, - CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions, - RSASignaturePadding.Pss); + result = await Core().ConfigureAwait(false); } catch (Exception ex) { - return Results.Text($"The re-enrollment CSR could not be parsed: {ex.Message}", - Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest); + EstInstruments.SimpleReEnrollFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + EstInstruments.SimpleReEnrollDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + throw; } - if (!cert.SubjectName.RawData.AsSpan().SequenceEqual(request.SubjectName.RawData)) + var statusCode = (result as IStatusCodeHttpResult)?.StatusCode ?? 200; + if (statusCode >= 400) { - return Results.Text("The re-enrollment CSR Subject must match the current certificate Subject.", - Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest); + EstInstruments.SimpleReEnrollFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error); } - - var currentSans = cert.Extensions.Where(x => x.Oid?.Value == Oids.SubjectAltName) - .Select(x => x.RawData) - .ToHashSet(); - var requestedSans = request.CertificateExtensions.Where(x => x.Oid?.Value == Oids.SubjectAltName) - .Select(x => x.RawData) - .ToHashSet(); - if (!currentSans.SetEquals(requestedSans)) + else { - return Results.Text( - "The re-enrollment CSR SubjectAltName extension must match the current certificate SubjectAltName extension.", - Constants.TextPlainMimeType, - Encoding.UTF8, - (int)HttpStatusCode.BadRequest); + EstInstruments.SimpleReEnrollSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); } - var newCert = await certificateAuthority.SignCertificateRequest( - request, - profileName, - user.Identity as ClaimsIdentity, - cert, - cancellationToken: cancellationToken).ConfigureAwait(false); - if (newCert is not SignCertificateResponse.Success success) - { - var error = (SignCertificateResponse.Error)newCert; - return Results.Text(string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType, - Encoding.UTF8, (int)HttpStatusCode.BadRequest); - } + EstInstruments.SimpleReEnrollDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); + return result; - var responseType = context.Request.GetTypedHeaders().Accept; - // This is a deviation from the RFC but is easier to parse. - if (responseType.Any(x => - x.MediaType.HasValue && - x.MediaType.Value.Equals(Constants.PemFile, StringComparison.OrdinalIgnoreCase))) + async Task Core() { - return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemFile); - } + var cert = await context.Connection.GetClientCertificateAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(context.Request.Body, Encoding.UTF8); + var requestBody = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + + if (cert == null) + { + return Results.Text("A client certificate is required for simple re-enrollment.", + Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest); + } + + if (!requestBody.TryVerifyTlsUniqueValue(out var proofOfPossessionError)) + { + return Results.Text(proofOfPossessionError, Constants.TextPlainMimeType, Encoding.UTF8, + (int)HttpStatusCode.BadRequest); + } + + CertificateRequest request; + try + { + request = PemEncoding.TryFind(requestBody, out _) + ? CertificateRequest.LoadSigningRequestPem( + requestBody, + HashAlgorithmName.SHA256, + CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions, + RSASignaturePadding.Pss) + : CertificateRequest.LoadSigningRequest( + requestBody.Base64DecodeBytes(), + HashAlgorithmName.SHA256, + CertificateRequestLoadOptions.UnsafeLoadCertificateExtensions, + RSASignaturePadding.Pss); + } + catch (Exception ex) + { + return Results.Text($"The re-enrollment CSR could not be parsed: {ex.Message}", + Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest); + } + + if (!cert.SubjectName.RawData.AsSpan().SequenceEqual(request.SubjectName.RawData)) + { + return Results.Text("The re-enrollment CSR Subject must match the current certificate Subject.", + Constants.TextPlainMimeType, Encoding.UTF8, (int)HttpStatusCode.BadRequest); + } - X509Certificate2[] content = [success.Certificate, ..success.Issuers]; - var signedResponse = new SignedData(version: 1, certificates: content); - var contentInfo = new CmsContentInfo( - Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName), - signedResponse); - var writer = new AsnWriter(AsnEncodingRules.DER); - contentInfo.Encode(writer); - var contentBytes = writer.Encode(); - return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly); + var currentSans = cert.Extensions.Where(x => x.Oid?.Value == Oids.SubjectAltName) + .Select(x => x.RawData) + .ToHashSet(); + var requestedSans = request.CertificateExtensions.Where(x => x.Oid?.Value == Oids.SubjectAltName) + .Select(x => x.RawData) + .ToHashSet(); + if (!currentSans.SetEquals(requestedSans)) + { + return Results.Text( + "The re-enrollment CSR SubjectAltName extension must match the current certificate SubjectAltName extension.", + Constants.TextPlainMimeType, + Encoding.UTF8, + (int)HttpStatusCode.BadRequest); + } + + var newCert = await certificateAuthority.SignCertificateRequest( + request, + profileName, + user.Identity as ClaimsIdentity, + cert, + cancellationToken: cancellationToken).ConfigureAwait(false); + if (newCert is not SignCertificateResponse.Success success) + { + var error = (SignCertificateResponse.Error)newCert; + return Results.Text(string.Join(Environment.NewLine, error.Errors), Constants.TextPlainMimeType, + Encoding.UTF8, (int)HttpStatusCode.BadRequest); + } + + var responseType = context.Request.GetTypedHeaders().Accept; + // This is a deviation from the RFC but is easier to parse. + if (responseType.Any(x => + x.MediaType.HasValue && + x.MediaType.Value.Equals(Constants.PemFile, StringComparison.OrdinalIgnoreCase))) + { + return Results.Text(success.Certificate.ToPemChain(success.Issuers), Constants.PemFile); + } + + X509Certificate2[] content = [success.Certificate, ..success.Issuers]; + var signedResponse = new SignedData(version: 1, certificates: content); + var contentInfo = new CmsContentInfo( + Oids.Pkcs7Signed.InitializeOid(Oids.Pkcs7SignedFriendlyName), + signedResponse); + var writer = new AsnWriter(AsnEncodingRules.DER); + contentInfo.Encode(writer); + var contentBytes = writer.Encode(); + return Results.Text(Convert.ToBase64String(contentBytes), Constants.PkiMimeTypeCertsOnly); + } } } diff --git a/src/opencertserver.est.server/MetricNames.cs b/src/opencertserver.est.server/MetricNames.cs new file mode 100644 index 0000000..b5aa878 --- /dev/null +++ b/src/opencertserver.est.server/MetricNames.cs @@ -0,0 +1,41 @@ +namespace OpenCertServer.Est.Server; + +/// +/// Centralised metric name constants for all EST OpenTelemetry instruments. +/// All metric instrument creation MUST reference these constants. +/// +internal static class MetricNames +{ + internal const string MeterName = "opencertserver.est"; + + // /cacerts + internal const string CaCertsRequests = "opencertserver.est.cacerts.requests"; + internal const string CaCertsSuccesses = "opencertserver.est.cacerts.successes"; + internal const string CaCertsFailures = "opencertserver.est.cacerts.failures"; + internal const string CaCertsDuration = "opencertserver.est.cacerts.duration"; + + // /simpleenroll + internal const string SimpleEnrollRequests = "opencertserver.est.simpleenroll.requests"; + internal const string SimpleEnrollSuccesses = "opencertserver.est.simpleenroll.successes"; + internal const string SimpleEnrollFailures = "opencertserver.est.simpleenroll.failures"; + internal const string SimpleEnrollDuration = "opencertserver.est.simpleenroll.duration"; + + // /simplereenroll + internal const string SimpleReEnrollRequests = "opencertserver.est.simplereenroll.requests"; + internal const string SimpleReEnrollSuccesses = "opencertserver.est.simplereenroll.successes"; + internal const string SimpleReEnrollFailures = "opencertserver.est.simplereenroll.failures"; + internal const string SimpleReEnrollDuration = "opencertserver.est.simplereenroll.duration"; + + // /csrattrs + internal const string CsrAttrsRequests = "opencertserver.est.csrattrs.requests"; + internal const string CsrAttrsSuccesses = "opencertserver.est.csrattrs.successes"; + internal const string CsrAttrsFailures = "opencertserver.est.csrattrs.failures"; + internal const string CsrAttrsDuration = "opencertserver.est.csrattrs.duration"; + + // /serverkeygen + internal const string ServerKeyGenRequests = "opencertserver.est.serverkeygen.requests"; + internal const string ServerKeyGenSuccesses = "opencertserver.est.serverkeygen.successes"; + internal const string ServerKeyGenFailures = "opencertserver.est.serverkeygen.failures"; + internal const string ServerKeyGenDuration = "opencertserver.est.serverkeygen.duration"; +} + diff --git a/src/opencertserver.est.server/TagKeys.cs b/src/opencertserver.est.server/TagKeys.cs new file mode 100644 index 0000000..54e86fc --- /dev/null +++ b/src/opencertserver.est.server/TagKeys.cs @@ -0,0 +1,21 @@ +namespace OpenCertServer.Est.Server; + +/// +/// Centralised tag (attribute) key constants for all EST OpenTelemetry measurements. +/// Use these keys when adding dimensions to counters, histograms and spans. +/// +internal static class TagKeys +{ + /// EST profile name (empty string for the default profile). + internal const string Profile = "est.profile"; + + /// + /// Structured error type; set to the exception class name or a short error code + /// on all failure counter and span recordings. + /// + internal const string ErrorType = "error.type"; + + /// HTTP response status code recorded on non-2xx result paths. + internal const string HttpStatusCode = "http.response.status_code"; +} + diff --git a/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature b/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature new file mode 100644 index 0000000..f1a2801 --- /dev/null +++ b/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature @@ -0,0 +1,31 @@ +Feature: OpenTelemetry Metrics + + Background: + Given a certificate server + And an EST client + And an OpenTelemetry meter listener + + Rule: EST metrics are emitted + + Scenario: EST /cacerts endpoint increments request counter + When I fetch the CA certs over EST + Then the EST cacerts request counter should be greater than zero + + Scenario: EST /simpleenroll endpoint increments request counter + When I enroll with a valid JWT + Then the EST simpleenroll request counter should be greater than zero + + Rule: OCSP metrics are emitted + + Scenario: OCSP request increments counter + When I enroll with a valid JWT + And I get a certificate + And I check the OCSP status of my certificate + Then the OCSP request counter should be greater than zero + + Rule: CRL metrics are emitted + + Scenario: CRL request increments counter + When I request the CRL + Then the CRL request counter should be greater than zero + diff --git a/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature.cs b/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature.cs new file mode 100644 index 0000000..d78b727 --- /dev/null +++ b/tests/opencertserver.certserver.tests/Features/OpenTelemetryMetrics.feature.cs @@ -0,0 +1,311 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace OpenCertServer.CertServer.Tests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public partial class OpenTelemetryMetricsFeature : object, Xunit.IClassFixture, Xunit.IAsyncLifetime + { + + private global::Reqnroll.ITestRunner testRunner; + + private Xunit.ITestOutputHelper _testOutputHelper; + + private static string[] featureTags = ((string[])(null)); + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "OpenTelemetry Metrics", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "OpenTelemetryMetrics.feature" +#line hidden + + public OpenTelemetryMetricsFeature(OpenTelemetryMetricsFeature.FixtureData fixtureData, Xunit.ITestOutputHelper testOutputHelper) + { + this._testOutputHelper = testOutputHelper; + } + + public static async global::System.Threading.Tasks.Task FeatureSetupAsync() + { + } + + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testOutputHelper); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + public virtual async global::System.Threading.Tasks.Task FeatureBackgroundAsync() + { +#line 3 + #line hidden +#line 4 + await testRunner.GivenAsync("a certificate server", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 5 + await testRunner.AndAsync("an EST client", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 6 + await testRunner.AndAsync("an OpenTelemetry meter listener", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/OpenTelemetryMetrics.feature.ndjson", 6); + } + + async System.Threading.Tasks.ValueTask Xunit.IAsyncLifetime.InitializeAsync() + { + try + { + await this.TestInitializeAsync(); + } + catch (System.Exception e1) + { + try + { + ((Xunit.IAsyncLifetime)(this)).DisposeAsync(); + } + catch (System.Exception e2) + { + throw new System.AggregateException("Test initialization failed", e1, e2); + } + throw; + } + } + + async System.Threading.Tasks.ValueTask System.IAsyncDisposable.DisposeAsync() + { + await this.TestTearDownAsync(); + } + + [global::Xunit.FactAttribute(DisplayName="EST /cacerts endpoint increments request counter")] + [global::Xunit.TraitAttribute("FeatureTitle", "OpenTelemetry Metrics")] + [global::Xunit.TraitAttribute("Description", "EST /cacerts endpoint increments request counter")] + public async global::System.Threading.Tasks.Task ESTCacertsEndpointIncrementsRequestCounter() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("EST /cacerts endpoint increments request counter", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = new global::Reqnroll.RuleInfo("EST metrics are emitted", null, tagsOfRule); +#line 10 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 3 + await this.FeatureBackgroundAsync(); +#line hidden +#line 11 + await testRunner.WhenAsync("I fetch the CA certs over EST", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync("the EST cacerts request counter should be greater than zero", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Xunit.FactAttribute(DisplayName="EST /simpleenroll endpoint increments request counter")] + [global::Xunit.TraitAttribute("FeatureTitle", "OpenTelemetry Metrics")] + [global::Xunit.TraitAttribute("Description", "EST /simpleenroll endpoint increments request counter")] + public async global::System.Threading.Tasks.Task ESTSimpleenrollEndpointIncrementsRequestCounter() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("EST /simpleenroll endpoint increments request counter", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = new global::Reqnroll.RuleInfo("EST metrics are emitted", null, tagsOfRule); +#line 14 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 3 + await this.FeatureBackgroundAsync(); +#line hidden +#line 15 + await testRunner.WhenAsync("I enroll with a valid JWT", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 16 + await testRunner.ThenAsync("the EST simpleenroll request counter should be greater than zero", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Xunit.FactAttribute(DisplayName="OCSP request increments counter")] + [global::Xunit.TraitAttribute("FeatureTitle", "OpenTelemetry Metrics")] + [global::Xunit.TraitAttribute("Description", "OCSP request increments counter")] + public async global::System.Threading.Tasks.Task OCSPRequestIncrementsCounter() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "2"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("OCSP request increments counter", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = new global::Reqnroll.RuleInfo("OCSP metrics are emitted", null, tagsOfRule); +#line 20 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 3 + await this.FeatureBackgroundAsync(); +#line hidden +#line 21 + await testRunner.WhenAsync("I enroll with a valid JWT", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 22 + await testRunner.AndAsync("I get a certificate", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 23 + await testRunner.AndAsync("I check the OCSP status of my certificate", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 24 + await testRunner.ThenAsync("the OCSP request counter should be greater than zero", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Xunit.FactAttribute(DisplayName="CRL request increments counter")] + [global::Xunit.TraitAttribute("FeatureTitle", "OpenTelemetry Metrics")] + [global::Xunit.TraitAttribute("Description", "CRL request increments counter")] + public async global::System.Threading.Tasks.Task CRLRequestIncrementsCounter() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "3"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("CRL request increments counter", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = new global::Reqnroll.RuleInfo("CRL metrics are emitted", null, tagsOfRule); +#line 28 + this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 3 + await this.FeatureBackgroundAsync(); +#line hidden +#line 29 + await testRunner.WhenAsync("I request the CRL", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 30 + await testRunner.ThenAsync("the CRL request counter should be greater than zero", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class FixtureData : object, Xunit.IAsyncLifetime + { + + async System.Threading.Tasks.ValueTask Xunit.IAsyncLifetime.InitializeAsync() + { + await OpenTelemetryMetricsFeature.FeatureSetupAsync(); + } + + async System.Threading.Tasks.ValueTask System.IAsyncDisposable.DisposeAsync() + { + await OpenTelemetryMetricsFeature.FeatureTearDownAsync(); + } + } + } +} +#pragma warning restore +#endregion diff --git a/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs b/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs new file mode 100644 index 0000000..f0228d8 --- /dev/null +++ b/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs @@ -0,0 +1,120 @@ +namespace OpenCertServer.CertServer.Tests.StepDefinitions; + +using System.Diagnostics.Metrics; +using System.Formats.Asn1; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.DependencyInjection; +using OpenCertServer.Ca.Utils; +using OpenCertServer.Ca.Utils.Ca; +using OpenCertServer.Ca.Utils.Ocsp; +using OpenCertServer.Ca.Utils.X509; +using Reqnroll; +using Xunit; + +public partial class CertificateServerFeatures +{ + private MeterListener _meterListener = null!; + private readonly Dictionary _metricCounters = new(); + + [Given("an OpenTelemetry meter listener")] + public void GivenAnOpenTelemetryMeterListener() + { + _meterListener = new MeterListener(); + _meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name.StartsWith("opencertserver.", StringComparison.OrdinalIgnoreCase)) + { + listener.EnableMeasurementEvents(instrument); + } + }; + _meterListener.SetMeasurementEventCallback((instrument, measurement, _, _) => + { + lock (_metricCounters) + { + _metricCounters.TryGetValue(instrument.Name, out var existing); + _metricCounters[instrument.Name] = existing + measurement; + } + }); + _meterListener.Start(); + } + + [When("I fetch the CA certs over EST")] + public async Task WhenIFetchTheCaCertsOverEst() + { + using var client = _server.CreateClient(); + var response = await client.GetAsync("/.well-known/est/rsa/cacerts").ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + [Then("the EST cacerts request counter should be greater than zero")] + public void ThenTheEstCacertsRequestCounterShouldBeGreaterThanZero() + { + _meterListener?.RecordObservableInstruments(); + lock (_metricCounters) + { + Assert.True( + _metricCounters.TryGetValue("opencertserver.est.cacerts.requests", out var count) && count > 0, + $"Expected opencertserver.est.cacerts.requests > 0, actual: {(_metricCounters.TryGetValue("opencertserver.est.cacerts.requests", out var c) ? c : 0)}"); + } + } + + [Then("the EST simpleenroll request counter should be greater than zero")] + public void ThenTheEstSimpleenrollRequestCounterShouldBeGreaterThanZero() + { + _meterListener?.RecordObservableInstruments(); + lock (_metricCounters) + { + Assert.True( + _metricCounters.TryGetValue("opencertserver.est.simpleenroll.requests", out var count) && count > 0, + $"Expected opencertserver.est.simpleenroll.requests > 0, actual: {(_metricCounters.TryGetValue("opencertserver.est.simpleenroll.requests", out var c) ? c : 0)}"); + } + } + + [When("I check the OCSP status of my certificate")] + public async Task WhenICheckTheOcspStatusOfMyCertificate() + { + var issuerCert = await GetIssuerCertAsync().ConfigureAwait(false); + var tbsRequest = new TbsRequest(requestList: + [ + new Request(CertId.Create(_certCollection[0], issuerCert, HashAlgorithmName.SHA256)) + ]); + var ocspRequest = new OcspRequest(tbsRequest); + var ocspResponse = await GetOcspResponse(ocspRequest).ConfigureAwait(false); + _scenarioContext["ocspResponse"] = ocspResponse; + } + + [Then("the OCSP request counter should be greater than zero")] + public void ThenTheOcspRequestCounterShouldBeGreaterThanZero() + { + _meterListener?.RecordObservableInstruments(); + lock (_metricCounters) + { + Assert.True( + _metricCounters.TryGetValue("opencertserver.ocsp.request.requests", out var count) && count > 0, + $"Expected opencertserver.ocsp.request.requests > 0, actual: {(_metricCounters.TryGetValue("opencertserver.ocsp.request.requests", out var c) ? c : 0)}"); + } + } + + [When("I request the CRL")] + public async Task WhenIRequestTheCrl() + { + using var client = _server.CreateClient(); + var response = await client.GetAsync("/ca/rsa/crl").ConfigureAwait(false); + // CRL endpoint may return 200 or different codes; we capture it for the assertion + _scenarioContext["crlStatusCode"] = (int)response.StatusCode; + } + + [Then("the CRL request counter should be greater than zero")] + public void ThenTheCrlRequestCounterShouldBeGreaterThanZero() + { + _meterListener?.RecordObservableInstruments(); + lock (_metricCounters) + { + Assert.True( + _metricCounters.TryGetValue("opencertserver.crl.request.requests", out var count) && count > 0, + $"Expected opencertserver.crl.request.requests > 0, actual: {(_metricCounters.TryGetValue("opencertserver.crl.request.requests", out var c) ? c : 0)}"); + } + } +} + diff --git a/tests/opencertserver.certserver.tests/Support/OpenTelemetryCollectorContainer.cs b/tests/opencertserver.certserver.tests/Support/OpenTelemetryCollectorContainer.cs new file mode 100644 index 0000000..0743de4 --- /dev/null +++ b/tests/opencertserver.certserver.tests/Support/OpenTelemetryCollectorContainer.cs @@ -0,0 +1,141 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +namespace OpenCertServer.CertServer.Tests.Support; + +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Hosts an OpenTelemetry Collector container for acceptance tests. +/// +internal sealed class OpenTelemetryCollectorContainer : IAsyncDisposable +{ + private const ushort OtlpGrpcPort = 4317; + private const ushort OtlpHttpPort = 4318; + private const ushort PrometheusPort = 9464; + private readonly IContainer _container; + private readonly HttpClient _httpClient = new(); + + /// + /// Initializes a new instance of the class. + /// + public OpenTelemetryCollectorContainer() + { + var configPath = ResolveConfigPath(); + _container = new ContainerBuilder("otel/opentelemetry-collector-contrib:latest") + .WithName($"dotauth-otel-{Guid.NewGuid():N}") + .WithPortBinding(OtlpGrpcPort, true) + .WithPortBinding(OtlpHttpPort, true) + .WithPortBinding(PrometheusPort, true) + .WithResourceMapping(new FileInfo(configPath), "/etc/otelcol-contrib/") + .WithCommand("--config=/etc/otelcol-contrib/otel-collector-config.yaml") + .WithWaitStrategy( + Wait.ForUnixContainer() + .UntilMessageIsLogged("Everything is ready")) + .Build(); + } + + /// + /// Gets the OTLP gRPC endpoint that the server should export telemetry to. + /// + public Uri OtlpGrpcEndpoint => new($"http://localhost:{_container.GetMappedPublicPort(OtlpGrpcPort)}"); + + /// + /// Gets the Prometheus scrape endpoint exposed by the collector. + /// + public Uri PrometheusEndpoint => new($"http://localhost:{_container.GetMappedPublicPort(PrometheusPort)}/metrics"); + + /// + /// Gets the OTLP HTTP endpoint that the server can export telemetry to. + /// + public Uri OtlpHttpEndpoint => new($"http://localhost:{_container.GetMappedPublicPort(OtlpHttpPort)}"); + + /// + /// Starts the collector container. + /// + /// The cancellation token. + public async Task StartAsync(CancellationToken cancellationToken = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); + await _container.StartAsync(timeoutCts.Token).ConfigureAwait(false); + } + + /// + /// Reads the collector logs, which include exported traces via the debug exporter. + /// + /// The cancellation token. + public Task ReadTracesAsync(CancellationToken cancellationToken = default) + { + return ReadLogsAsync(cancellationToken); + } + + /// + /// Reads the collector logs, which include exported metrics via the debug exporter. + /// + /// The cancellation token. + public Task ReadMetricsAsync(CancellationToken cancellationToken = default) + { + return ReadLogsAsync(cancellationToken); + } + + /// + /// Reads the Prometheus metrics endpoint from the collector. + /// + /// The cancellation token. + public Task ReadPrometheusMetricsAsync(CancellationToken cancellationToken = default) + { + return _httpClient.GetStringAsync(PrometheusEndpoint, cancellationToken); + } + + /// + /// Stops and disposes the collector container resources. + /// + public async ValueTask DisposeAsync() + { + await _container.DisposeAsync().ConfigureAwait(false); + _httpClient.Dispose(); + } + + /// + /// Reads the collector logs from the container. + /// + /// The cancellation token. + /// The combined standard output and error logs. + private async Task ReadLogsAsync(CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var result = await _container + .GetLogsAsync(DateTime.UnixEpoch, now, false, cancellationToken) + .ConfigureAwait(false); + + return string.Concat(result.Stdout, Environment.NewLine, result.Stderr); + } + + /// + /// Resolves the collector configuration file from either the test output directory or the project support folder. + /// + /// The absolute config file path. + /// Thrown when the collector config cannot be located. + private static string ResolveConfigPath() + { + var outputPath = Path.Combine(AppContext.BaseDirectory, "otel-collector-config.yaml"); + if (File.Exists(outputPath)) + { + return outputPath; + } + + var projectPath = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "../../../Support/otel-collector-config.yaml")); + if (File.Exists(projectPath)) + { + return projectPath; + } + + throw new FileNotFoundException("The OpenTelemetry collector config file could not be found.", outputPath); + } +} diff --git a/tests/opencertserver.certserver.tests/Support/otel-collector-config.yaml b/tests/opencertserver.certserver.tests/Support/otel-collector-config.yaml new file mode 100644 index 0000000..1b6cce6 --- /dev/null +++ b/tests/opencertserver.certserver.tests/Support/otel-collector-config.yaml @@ -0,0 +1,30 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 1s + send_batch_size: 1024 + +exporters: + debug: + verbosity: detailed + prometheus: + endpoint: "0.0.0.0:9464" + +service: + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [debug, prometheus] + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug] + diff --git a/tests/opencertserver.certserver.tests/opencertserver.certserver.tests.csproj b/tests/opencertserver.certserver.tests/opencertserver.certserver.tests.csproj index 2f00a15..50d2a0d 100644 --- a/tests/opencertserver.certserver.tests/opencertserver.certserver.tests.csproj +++ b/tests/opencertserver.certserver.tests/opencertserver.certserver.tests.csproj @@ -10,6 +10,7 @@ + all From d5e16da88883214abf8715f261e71c3a339a5bd7 Mon Sep 17 00:00:00 2001 From: Jacob Reimers Date: Sat, 18 Apr 2026 17:01:31 +0200 Subject: [PATCH 2/5] Improve OTel tracing --- src/opencertserver.ca.server/ActivityNames.cs | 3 +- src/opencertserver.ca.server/CaInstruments.cs | 6 ++ .../Handlers/CertificateRetrievalHandler.cs | 57 ++++++++++++------- src/opencertserver.ca.server/MetricNames.cs | 6 ++ .../Handlers/CaCertHandler.cs | 1 + .../Handlers/CsrAttributesHandler.cs | 1 + .../Handlers/ServerKeyGenHandler.cs | 1 + .../Handlers/SimpleEnrollHandler.cs | 1 + .../Handlers/SimpleReEnrollHandler.cs | 1 + src/opencertserver.est.server/TagKeys.cs | 2 + 10 files changed, 59 insertions(+), 20 deletions(-) diff --git a/src/opencertserver.ca.server/ActivityNames.cs b/src/opencertserver.ca.server/ActivityNames.cs index 686df5d..c6d383c 100644 --- a/src/opencertserver.ca.server/ActivityNames.cs +++ b/src/opencertserver.ca.server/ActivityNames.cs @@ -11,6 +11,7 @@ internal static class ActivityNames internal const string CrlGeneration = "opencertserver.crl.generation"; internal const string CsrSign = "opencertserver.ca.csr"; internal const string Revoke = "opencertserver.ca.revoke"; - internal const string Inventory = "opencertserver.ca.inventory"; + internal const string Inventory = "opencertserver.ca.inventory"; + internal const string CertificateRetrieval = "opencertserver.ca.certretrieval"; } diff --git a/src/opencertserver.ca.server/CaInstruments.cs b/src/opencertserver.ca.server/CaInstruments.cs index 690ddae..4636710 100644 --- a/src/opencertserver.ca.server/CaInstruments.cs +++ b/src/opencertserver.ca.server/CaInstruments.cs @@ -45,5 +45,11 @@ internal static class CaInstruments internal static readonly Counter InventorySuccesses = Meter.CreateCounter (MetricNames.InventorySuccesses, description: "Successful inventory responses"); internal static readonly Counter InventoryFailures = Meter.CreateCounter (MetricNames.InventoryFailures, description: "Failed inventory responses"); internal static readonly Histogram InventoryDuration = Meter.CreateHistogram (MetricNames.InventoryDuration, "s", "Duration of inventory requests"); + + // /ca/certificates (retrieval by thumbprint / id) + internal static readonly Counter CertRetrievalRequests = Meter.CreateCounter (MetricNames.CertRetrievalRequests, description: "Total certificate retrieval requests"); + internal static readonly Counter CertRetrievalSuccesses = Meter.CreateCounter (MetricNames.CertRetrievalSuccesses, description: "Successful certificate retrievals"); + internal static readonly Counter CertRetrievalFailures = Meter.CreateCounter (MetricNames.CertRetrievalFailures, description: "Failed certificate retrievals"); + internal static readonly Histogram CertRetrievalDuration = Meter.CreateHistogram (MetricNames.CertRetrievalDuration, "s", "Duration of certificate retrieval requests"); } diff --git a/src/opencertserver.ca.server/Handlers/CertificateRetrievalHandler.cs b/src/opencertserver.ca.server/Handlers/CertificateRetrievalHandler.cs index 25aed7f..5438160 100644 --- a/src/opencertserver.ca.server/Handlers/CertificateRetrievalHandler.cs +++ b/src/opencertserver.ca.server/Handlers/CertificateRetrievalHandler.cs @@ -1,5 +1,6 @@ namespace OpenCertServer.Ca.Server.Handlers; +using System.Diagnostics; using System.Security.Cryptography; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -11,27 +12,45 @@ public static class CertificateRetrievalHandler public static async Task HandleGet(HttpContext context) { - var store = context.RequestServices.GetRequiredService(); - var thumbprints = context.Request.Query["thumbprint"]; - var ids = context.Request.Query["id"]; - var thumbCerts = store.GetCertificatesByThumbprint( - thumbprints.Where(s => s != null) - .Select(tp => tp.AsMemory())); - var idCerts = store.GetCertificatesById( - context.RequestAborted, - ids.Where(s => s != null) - .Select(tp => new ReadOnlyMemory(Convert.FromHexString(tp!)))); + CaInstruments.CertRetrievalRequests.Add(1); + var sw = Stopwatch.GetTimestamp(); + using var activity = CaInstruments.ActivitySource.StartActivity(ActivityNames.CertificateRetrieval); + try + { + var store = context.RequestServices.GetRequiredService(); + var thumbprints = context.Request.Query["thumbprint"]; + var ids = context.Request.Query["id"]; + var thumbCerts = store.GetCertificatesByThumbprint( + thumbprints.Where(s => s != null) + .Select(tp => tp.AsMemory())); + var idCerts = store.GetCertificatesById( + context.RequestAborted, + ids.Where(s => s != null) + .Select(tp => new ReadOnlyMemory(Convert.FromHexString(tp!)))); + + context.Response.ContentType = "application/x-pem-file"; + var bodyWriter = context.Response.BodyWriter; + await foreach (var cert in thumbCerts.Concat(idCerts).ConfigureAwait(false)) + { + var pem = PemEncoding.WriteUtf8("CERTIFICATE"u8, cert.RawData); + await bodyWriter.WriteAsync(pem).ConfigureAwait(false); + await bodyWriter.WriteAsync(NewLine).ConfigureAwait(false); + } - context.Response.ContentType = "application/x-pem-file"; - var bodyWriter = context.Response.BodyWriter; - await foreach (var cert in thumbCerts.Concat(idCerts).ConfigureAwait(false)) + await bodyWriter.FlushAsync().ConfigureAwait(false); + await bodyWriter.CompleteAsync().ConfigureAwait(false); + CaInstruments.CertRetrievalSuccesses.Add(1); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) { - var pem = PemEncoding.WriteUtf8("CERTIFICATE"u8, cert.RawData); - await bodyWriter.WriteAsync(pem).ConfigureAwait(false); - await bodyWriter.WriteAsync(NewLine).ConfigureAwait(false); + CaInstruments.CertRetrievalFailures.Add(1); + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + finally + { + CaInstruments.CertRetrievalDuration.Record(Stopwatch.GetElapsedTime(sw).TotalSeconds); } - - await bodyWriter.FlushAsync().ConfigureAwait(false); - await bodyWriter.CompleteAsync().ConfigureAwait(false); } } diff --git a/src/opencertserver.ca.server/MetricNames.cs b/src/opencertserver.ca.server/MetricNames.cs index 8c23407..25ef39f 100644 --- a/src/opencertserver.ca.server/MetricNames.cs +++ b/src/opencertserver.ca.server/MetricNames.cs @@ -43,5 +43,11 @@ internal static class MetricNames internal const string InventorySuccesses = "opencertserver.ca.inventory.successes"; internal const string InventoryFailures = "opencertserver.ca.inventory.failures"; internal const string InventoryDuration = "opencertserver.ca.inventory.duration"; + + // /ca/certificates (retrieval by thumbprint / id) + internal const string CertRetrievalRequests = "opencertserver.ca.certretrieval.requests"; + internal const string CertRetrievalSuccesses = "opencertserver.ca.certretrieval.successes"; + internal const string CertRetrievalFailures = "opencertserver.ca.certretrieval.failures"; + internal const string CertRetrievalDuration = "opencertserver.ca.certretrieval.duration"; } diff --git a/src/opencertserver.est.server/Handlers/CaCertHandler.cs b/src/opencertserver.est.server/Handlers/CaCertHandler.cs index 8c307ef..75fc3e3 100644 --- a/src/opencertserver.est.server/Handlers/CaCertHandler.cs +++ b/src/opencertserver.est.server/Handlers/CaCertHandler.cs @@ -25,6 +25,7 @@ public static async Task HandleProfile( EstInstruments.CaCertsRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.CaCerts); + activity?.AddTag(TagKeys.ProfileName, profileName); try { var export = await certificates(profileName, cancellationToken).ConfigureAwait(false); diff --git a/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs b/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs index be896da..1fa0deb 100644 --- a/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs +++ b/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs @@ -25,6 +25,7 @@ public static async Task HandleProfile( EstInstruments.CsrAttrsRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.CsrAttrs); + activity?.AddTag(TagKeys.ProfileName, profileName); try { var attributes = await loader.GetTemplate(profileName, user, cancellationToken).ConfigureAwait(false); diff --git a/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs b/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs index 772cb59..b4192e5 100644 --- a/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs +++ b/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs @@ -44,6 +44,7 @@ public static async Task HandleProfile( EstInstruments.ServerKeyGenRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.ServerKeyGen); + activity?.AddTag(TagKeys.ProfileName, profileName); IResult result; try { diff --git a/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs b/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs index b09466e..9ff947c 100644 --- a/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs +++ b/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs @@ -42,6 +42,7 @@ public static async Task HandleProfile( EstInstruments.SimpleEnrollRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.SimpleEnroll); + activity?.AddTag(TagKeys.ProfileName, profileName); IResult result; try { diff --git a/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs b/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs index 6de0dc8..7a63813 100644 --- a/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs +++ b/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs @@ -37,6 +37,7 @@ public static async Task HandleProfile( EstInstruments.SimpleReEnrollRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.SimpleReEnroll); + activity?.AddTag(TagKeys.ProfileName, profileName); IResult result; try { diff --git a/src/opencertserver.est.server/TagKeys.cs b/src/opencertserver.est.server/TagKeys.cs index 54e86fc..ba988e0 100644 --- a/src/opencertserver.est.server/TagKeys.cs +++ b/src/opencertserver.est.server/TagKeys.cs @@ -17,5 +17,7 @@ internal static class TagKeys /// HTTP response status code recorded on non-2xx result paths. internal const string HttpStatusCode = "http.response.status_code"; + + internal const string ProfileName = "est.profile"; } From 1ab96c024dbf7951111a5e5612aac8bb6d27699b Mon Sep 17 00:00:00 2001 From: Jacob Reimers Date: Sat, 18 Apr 2026 17:18:19 +0200 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- OpenTelemetryMetricsTraces.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/OpenTelemetryMetricsTraces.md b/OpenTelemetryMetricsTraces.md index de9b49e..70d1473 100644 --- a/OpenTelemetryMetricsTraces.md +++ b/OpenTelemetryMetricsTraces.md @@ -196,5 +196,4 @@ This document lists relevant OpenTelemetry metrics and traces for OpenCertServer - `opencertserver.tls.errors`: Counter for TLS-related errors. - `opencertserver.errors.internal`: Counter for internal server errors. -**Collection Location**: In middleware or base handlers. -/Users/jacobreimers/code/opencertserver/OpenTelemetryMetricsTraces.md +**Collection Location**: In middleware or base handlers. From 6ff757b35ec11ab118097d6581ad5e30bbb3276d Mon Sep 17 00:00:00 2001 From: Jacob Reimers Date: Sat, 18 Apr 2026 17:20:13 +0200 Subject: [PATCH 4/5] Code cleanup --- src/opencertserver.est.server/Handlers/CaCertHandler.cs | 2 +- .../Handlers/CsrAttributesHandler.cs | 2 +- .../Handlers/ServerKeyGenHandler.cs | 2 +- .../Handlers/SimpleEnrollHandler.cs | 2 +- .../Handlers/SimpleReEnrollHandler.cs | 2 +- src/opencertserver.est.server/TagKeys.cs | 2 -- src/opencertserver.tss.net/Globs.cs | 1 - src/opencertserver.tss.net/Policy.cs | 2 -- src/opencertserver.tss.net/Properties/AssemblyInfo.cs | 1 - .../StepDefinitions/EstConformance.cs | 1 - .../StepDefinitions/OpenTelemetryMetrics.cs | 6 ------ 11 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/opencertserver.est.server/Handlers/CaCertHandler.cs b/src/opencertserver.est.server/Handlers/CaCertHandler.cs index 75fc3e3..4bef53d 100644 --- a/src/opencertserver.est.server/Handlers/CaCertHandler.cs +++ b/src/opencertserver.est.server/Handlers/CaCertHandler.cs @@ -25,7 +25,7 @@ public static async Task HandleProfile( EstInstruments.CaCertsRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.CaCerts); - activity?.AddTag(TagKeys.ProfileName, profileName); + activity?.AddTag(TagKeys.Profile, profileName); try { var export = await certificates(profileName, cancellationToken).ConfigureAwait(false); diff --git a/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs b/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs index 1fa0deb..e79ff01 100644 --- a/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs +++ b/src/opencertserver.est.server/Handlers/CsrAttributesHandler.cs @@ -25,7 +25,7 @@ public static async Task HandleProfile( EstInstruments.CsrAttrsRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.CsrAttrs); - activity?.AddTag(TagKeys.ProfileName, profileName); + activity?.AddTag(TagKeys.Profile, profileName); try { var attributes = await loader.GetTemplate(profileName, user, cancellationToken).ConfigureAwait(false); diff --git a/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs b/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs index b4192e5..981f1bc 100644 --- a/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs +++ b/src/opencertserver.est.server/Handlers/ServerKeyGenHandler.cs @@ -44,7 +44,7 @@ public static async Task HandleProfile( EstInstruments.ServerKeyGenRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.ServerKeyGen); - activity?.AddTag(TagKeys.ProfileName, profileName); + activity?.AddTag(TagKeys.Profile, profileName); IResult result; try { diff --git a/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs b/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs index 9ff947c..c169e7a 100644 --- a/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs +++ b/src/opencertserver.est.server/Handlers/SimpleEnrollHandler.cs @@ -42,7 +42,7 @@ public static async Task HandleProfile( EstInstruments.SimpleEnrollRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.SimpleEnroll); - activity?.AddTag(TagKeys.ProfileName, profileName); + activity?.AddTag(TagKeys.Profile, profileName); IResult result; try { diff --git a/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs b/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs index 7a63813..074c444 100644 --- a/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs +++ b/src/opencertserver.est.server/Handlers/SimpleReEnrollHandler.cs @@ -37,7 +37,7 @@ public static async Task HandleProfile( EstInstruments.SimpleReEnrollRequests.Add(1); var sw = Stopwatch.GetTimestamp(); using var activity = EstInstruments.ActivitySource.StartActivity(ActivityNames.SimpleReEnroll); - activity?.AddTag(TagKeys.ProfileName, profileName); + activity?.AddTag(TagKeys.Profile, profileName); IResult result; try { diff --git a/src/opencertserver.est.server/TagKeys.cs b/src/opencertserver.est.server/TagKeys.cs index ba988e0..54e86fc 100644 --- a/src/opencertserver.est.server/TagKeys.cs +++ b/src/opencertserver.est.server/TagKeys.cs @@ -17,7 +17,5 @@ internal static class TagKeys /// HTTP response status code recorded on non-2xx result paths. internal const string HttpStatusCode = "http.response.status_code"; - - internal const string ProfileName = "est.profile"; } diff --git a/src/opencertserver.tss.net/Globs.cs b/src/opencertserver.tss.net/Globs.cs index b0a9830..20282ec 100644 --- a/src/opencertserver.tss.net/Globs.cs +++ b/src/opencertserver.tss.net/Globs.cs @@ -7,7 +7,6 @@ using System.Numerics; using System.Reflection; using System.Resources; -using System.Runtime.InteropServices; using System.Text; namespace OpenCertServer.Tpm2Lib; diff --git a/src/opencertserver.tss.net/Policy.cs b/src/opencertserver.tss.net/Policy.cs index 23c6dc6..f33c30f 100644 --- a/src/opencertserver.tss.net/Policy.cs +++ b/src/opencertserver.tss.net/Policy.cs @@ -4,8 +4,6 @@ */ using System.Runtime.Serialization; -using System.Runtime.Serialization.Json; -using System.Text; namespace OpenCertServer.Tpm2Lib; diff --git a/src/opencertserver.tss.net/Properties/AssemblyInfo.cs b/src/opencertserver.tss.net/Properties/AssemblyInfo.cs index 2e66062..25fd91e 100644 --- a/src/opencertserver.tss.net/Properties/AssemblyInfo.cs +++ b/src/opencertserver.tss.net/Properties/AssemblyInfo.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following diff --git a/tests/opencertserver.certserver.tests/StepDefinitions/EstConformance.cs b/tests/opencertserver.certserver.tests/StepDefinitions/EstConformance.cs index f40f72c..b792be0 100644 --- a/tests/opencertserver.certserver.tests/StepDefinitions/EstConformance.cs +++ b/tests/opencertserver.certserver.tests/StepDefinitions/EstConformance.cs @@ -1,6 +1,5 @@ using System.Formats.Asn1; using OpenCertServer.Ca.Utils.Ca; -using OpenCertServer.Est.Server.Handlers; using OpenCertServer.Est.Server.Response; namespace OpenCertServer.CertServer.Tests.StepDefinitions; diff --git a/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs b/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs index f0228d8..d8cf1d2 100644 --- a/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs +++ b/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs @@ -1,14 +1,8 @@ namespace OpenCertServer.CertServer.Tests.StepDefinitions; using System.Diagnostics.Metrics; -using System.Formats.Asn1; using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.DependencyInjection; -using OpenCertServer.Ca.Utils; -using OpenCertServer.Ca.Utils.Ca; using OpenCertServer.Ca.Utils.Ocsp; -using OpenCertServer.Ca.Utils.X509; using Reqnroll; using Xunit; From 5d972a0771fc1f883d0e98c06a2ab5047c18a2fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:24:50 +0000 Subject: [PATCH 5/5] Add AfterScenario hook to dispose MeterListener and remove unused usings Agent-Logs-Url: https://github.com/jjrdk/opencertserver/sessions/d87a50af-e7f6-49cc-b1db-35e94ccd0339 Co-authored-by: jjrdk <149390+jjrdk@users.noreply.github.com> --- .../StepDefinitions/OpenTelemetryMetrics.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs b/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs index d8cf1d2..51c8a14 100644 --- a/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs +++ b/tests/opencertserver.certserver.tests/StepDefinitions/OpenTelemetryMetrics.cs @@ -8,7 +8,7 @@ namespace OpenCertServer.CertServer.Tests.StepDefinitions; public partial class CertificateServerFeatures { - private MeterListener _meterListener = null!; + private MeterListener? _meterListener; private readonly Dictionary _metricCounters = new(); [Given("an OpenTelemetry meter listener")] @@ -110,5 +110,12 @@ public void ThenTheCrlRequestCounterShouldBeGreaterThanZero() $"Expected opencertserver.crl.request.requests > 0, actual: {(_metricCounters.TryGetValue("opencertserver.crl.request.requests", out var c) ? c : 0)}"); } } + + [AfterScenario] + public void DisposeMeterListener() + { + _meterListener?.Dispose(); + _meterListener = null; + } }