Files
certctl/internal/service/est_counters.go
T
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

230 lines
8.7 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package service
import (
"context"
"sync/atomic"
"time"
"github.com/certctl-io/certctl/internal/trustanchor"
)
// EST RFC 7030 hardening master bundle Phase 7.1.
//
// estCounterTab is the in-memory equivalent of a Prometheus
// `certctl_est_enrollments_total{status="..."}` metric. We don't take a
// Prometheus dependency here (the project doesn't expose /metrics today;
// that's a separate decision). The admin GUI's "EST Profiles" tab calls
// the GET /api/v1/admin/est/profiles endpoint, which calls
// ESTService.Stats() to render the counter snapshot.
//
// Concurrency: every field is read/written via sync/atomic so the
// service hot path stays lock-free.
// Counter labels — keep in sync with snapshot() + the admin GUI's
// counter-grid renderer. New labels MUST be added in three places:
// constants below, snapshot()'s map, and inc()'s switch.
const (
estCounterSuccessSimpleEnroll = "success_simpleenroll"
estCounterSuccessSimpleReEnroll = "success_simplereenroll"
estCounterSuccessServerKeygen = "success_serverkeygen"
estCounterAuthFailedBasic = "auth_failed_basic"
estCounterAuthFailedMTLS = "auth_failed_mtls"
estCounterAuthFailedChannelBind = "auth_failed_channel_binding"
estCounterCSRInvalid = "csr_invalid"
estCounterCSRPolicyViolation = "csr_policy_violation"
estCounterCSRSignatureMismatch = "csr_signature_mismatch"
estCounterRateLimited = "rate_limited"
estCounterIssuerError = "issuer_error"
estCounterInternalError = "internal_error"
)
type estCounterTab struct {
successSimpleEnroll atomic.Uint64
successSimpleReEnroll atomic.Uint64
successServerKeygen atomic.Uint64
authFailedBasic atomic.Uint64
authFailedMTLS atomic.Uint64
authFailedChannelBind atomic.Uint64
csrInvalid atomic.Uint64
csrPolicyViolation atomic.Uint64
csrSignatureMismatch atomic.Uint64
rateLimited atomic.Uint64
issuerError atomic.Uint64
internalError atomic.Uint64
}
// snapshot returns a zero-allocation copy of the current counter values
// keyed by the same label strings inc() accepts.
func (c *estCounterTab) snapshot() map[string]uint64 {
if c == nil {
return map[string]uint64{}
}
return map[string]uint64{
estCounterSuccessSimpleEnroll: c.successSimpleEnroll.Load(),
estCounterSuccessSimpleReEnroll: c.successSimpleReEnroll.Load(),
estCounterSuccessServerKeygen: c.successServerKeygen.Load(),
estCounterAuthFailedBasic: c.authFailedBasic.Load(),
estCounterAuthFailedMTLS: c.authFailedMTLS.Load(),
estCounterAuthFailedChannelBind: c.authFailedChannelBind.Load(),
estCounterCSRInvalid: c.csrInvalid.Load(),
estCounterCSRPolicyViolation: c.csrPolicyViolation.Load(),
estCounterCSRSignatureMismatch: c.csrSignatureMismatch.Load(),
estCounterRateLimited: c.rateLimited.Load(),
estCounterIssuerError: c.issuerError.Load(),
estCounterInternalError: c.internalError.Load(),
}
}
// inc advances the counter matching the given label. Unknown labels
// fall through to internal_error so an enum drift doesn't silently
// lose counts.
func (c *estCounterTab) inc(label string) {
if c == nil {
return
}
switch label {
case estCounterSuccessSimpleEnroll:
c.successSimpleEnroll.Add(1)
case estCounterSuccessSimpleReEnroll:
c.successSimpleReEnroll.Add(1)
case estCounterSuccessServerKeygen:
c.successServerKeygen.Add(1)
case estCounterAuthFailedBasic:
c.authFailedBasic.Add(1)
case estCounterAuthFailedMTLS:
c.authFailedMTLS.Add(1)
case estCounterAuthFailedChannelBind:
c.authFailedChannelBind.Add(1)
case estCounterCSRInvalid:
c.csrInvalid.Add(1)
case estCounterCSRPolicyViolation:
c.csrPolicyViolation.Add(1)
case estCounterCSRSignatureMismatch:
c.csrSignatureMismatch.Add(1)
case estCounterRateLimited:
c.rateLimited.Add(1)
case estCounterIssuerError:
c.issuerError.Add(1)
default:
c.internalError.Add(1)
}
}
// ESTStatsSnapshot is the per-profile observability view the admin
// GET endpoint renders. Mirrors IntuneStatsSnapshot's shape so the GUI
// can re-use the same counter-grid component.
//
// EST RFC 7030 hardening master bundle Phase 7.1.
type ESTStatsSnapshot struct {
PathID string `json:"path_id"`
IssuerID string `json:"issuer_id"`
ProfileID string `json:"profile_id,omitempty"`
Counters map[string]uint64 `json:"counters"`
MTLSEnabled bool `json:"mtls_enabled"`
BasicConfigured bool `json:"basic_auth_configured"`
ServerKeygen bool `json:"server_keygen_enabled"`
TrustAnchors []ESTTrustAnchorInfo `json:"trust_anchors,omitempty"`
TrustAnchorPath string `json:"trust_anchor_path,omitempty"`
Now time.Time `json:"now"`
}
// ESTTrustAnchorInfo is the per-cert public summary of one trust anchor
// in the holder's pool. Same shape as IntuneTrustAnchorInfo.
type ESTTrustAnchorInfo struct {
Subject string `json:"subject"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysToExpiry int `json:"days_to_expiry"`
Expired bool `json:"expired"`
}
// Stats returns the per-profile observability snapshot. Safe for
// concurrent callers — every counter access is atomic + the trust-
// anchor walk is a per-snapshot copy.
func (s *ESTService) Stats(now time.Time) ESTStatsSnapshot {
out := ESTStatsSnapshot{
PathID: s.estPathIDForLog,
IssuerID: s.issuerID,
ProfileID: s.profileID,
Counters: s.counters.snapshot(),
MTLSEnabled: s.estMTLSConfigured,
BasicConfigured: s.estBasicConfigured,
ServerKeygen: s.estServerKeygenEnabled,
Now: now,
}
if s.estTrustAnchor != nil {
out.TrustAnchorPath = s.estTrustAnchor.Path()
for _, c := range s.estTrustAnchor.Get() {
daysToExpiry := int(c.NotAfter.Sub(now).Hours() / 24)
out.TrustAnchors = append(out.TrustAnchors, ESTTrustAnchorInfo{
Subject: c.Subject.CommonName,
NotBefore: c.NotBefore,
NotAfter: c.NotAfter,
DaysToExpiry: daysToExpiry,
Expired: now.After(c.NotAfter),
})
}
}
return out
}
// ReloadTrust forces a SIGHUP-equivalent reload of the per-profile
// EST mTLS trust anchor pool. Returns nil on success; the configured
// holder error otherwise (typically a parse error from a half-rotated
// bundle file). Mirror of SCEPService.ReloadIntuneTrust.
//
// Returns ErrESTMTLSDisabled when the profile doesn't have an mTLS
// trust anchor configured (admin handler maps to HTTP 409).
//
// Phase 11.3: emits AuditActionESTTrustAnchorReloaded on successful
// reload so operators have a typed grep target for "who rotated the
// trust bundle for which profile + when". The caller-supplied ctx is
// forwarded into RecordEvent so the audit row carries the same
// request-scoped trace identifiers as the rest of the admin pipeline,
// and so the contextcheck linter doesn't flag the admin handler for
// silently dropping its r.Context() at the service boundary.
func (s *ESTService) ReloadTrust(ctx context.Context) error {
if s.estTrustAnchor == nil {
return ErrESTMTLSDisabled
}
if err := s.estTrustAnchor.Reload(); err != nil {
return err
}
if s.auditService != nil {
details := map[string]interface{}{
"path_id": s.estPathIDForLog,
"trust_anchor_path": s.estTrustAnchor.Path(),
"protocol": "EST",
}
_ = s.auditService.RecordEvent(ctx, "est-admin", "system",
AuditActionESTTrustAnchorReloaded, "trust_anchor", s.estPathIDForLog, details)
}
return nil
}
// ErrESTMTLSDisabled signals the admin handler that an EST profile
// doesn't have mTLS configured. Maps to HTTP 409 Conflict.
var ErrESTMTLSDisabled = newESTAdminError("EST profile mTLS not enabled — no trust anchor to reload")
func newESTAdminError(msg string) error { return &estAdminError{msg: msg} }
type estAdminError struct{ msg string }
func (e *estAdminError) Error() string { return e.msg }
// SetESTAdminMetadata records the per-profile observability hints the
// AdminEST handler needs to render the Profiles tab. cmd/server/main.go
// invokes this once at startup with the data already in scope from the
// per-profile loop. Idempotent. Consolidated into one setter so the
// public surface stays narrow + every metadata field moves together.
func (s *ESTService) SetESTAdminMetadata(pathID string, mtlsEnabled, basicConfigured, serverKeygenEnabled bool, trustAnchor *trustanchor.Holder) {
s.estPathIDForLog = pathID
s.estMTLSConfigured = mtlsEnabled
s.estBasicConfigured = basicConfigured
s.estServerKeygenEnabled = serverKeygenEnabled
s.estTrustAnchor = trustAnchor
}