mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 20:18:52 +00:00
5dc698307b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit bc6039a (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
681 lines
24 KiB
Go
681 lines
24 KiB
Go
// Package globalsign implements the issuer.Connector interface for GlobalSign Atlas HVCA.
|
|
//
|
|
// GlobalSign Atlas HVCA (Hosted Validation CA) is an enterprise certificate authority
|
|
// offering DV and OV certificates. Unlike synchronous issuers (Vault, step-ca), GlobalSign
|
|
// uses an asynchronous order model with serial number polling: submit a certificate order,
|
|
// receive a serial number immediately, then poll to check when the cert is available.
|
|
//
|
|
// This connector maps to certctl's existing job state machine:
|
|
// - IssueCertificate submits the order and returns the serial number. The cert PEM
|
|
// is typically available within seconds for DV certs.
|
|
// - GetOrderStatus polls via the serial number to retrieve the cert when ready.
|
|
//
|
|
// Authentication: mTLS client certificate (mutual TLS handshake) PLUS API key/secret
|
|
// headers on every request. This is a "double auth" pattern.
|
|
// - TLS client certificate: loaded from disk via tls.LoadX509KeyPair()
|
|
// - API key/secret: sent as custom HTTP headers (ApiKey, ApiSecret)
|
|
//
|
|
// GlobalSign Atlas HVCA API used:
|
|
//
|
|
// POST /v2/certificates - Submit certificate order, returns serial number
|
|
// GET /v2/certificates/{serial} - Get certificate PEM by serial number
|
|
// PUT /v2/certificates/{serial}/revoke - Revoke certificate (no reason code required)
|
|
// GET /v2/certificates - List certificates (for config validation)
|
|
package globalsign
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/issuer"
|
|
"github.com/certctl-io/certctl/internal/connector/issuer/asyncpoll"
|
|
"github.com/certctl-io/certctl/internal/connector/issuer/mtlscache"
|
|
"github.com/certctl-io/certctl/internal/secret"
|
|
)
|
|
|
|
// Config represents the GlobalSign Atlas HVCA issuer connector configuration.
|
|
type Config struct {
|
|
// APIUrl is the GlobalSign Atlas HVCA API base URL (region-aware).
|
|
// Examples: https://emea.api.hvca.globalsign.com:8443/v2/ (EMEA region)
|
|
// Required. Set via CERTCTL_GLOBALSIGN_API_URL environment variable.
|
|
APIUrl string `json:"api_url"`
|
|
|
|
// APIKey is the GlobalSign API key for request authentication.
|
|
// Required. Set via CERTCTL_GLOBALSIGN_API_KEY environment variable.
|
|
//
|
|
// Type: *secret.Ref (audit fix #6 Phase 2). Never stringifies;
|
|
// MarshalJSON returns "[redacted]"; bytes are zeroed after each
|
|
// header write via Ref.Use.
|
|
APIKey *secret.Ref `json:"api_key"`
|
|
|
|
// APISecret is the GlobalSign API secret for request authentication.
|
|
// Required. Set via CERTCTL_GLOBALSIGN_API_SECRET environment variable.
|
|
// Same *secret.Ref protections as APIKey.
|
|
APISecret *secret.Ref `json:"api_secret"`
|
|
|
|
// ClientCertPath is the filesystem path to the mTLS client certificate PEM file.
|
|
// The certificate must be signed by GlobalSign and loaded for TLS handshake.
|
|
// Required. Set via CERTCTL_GLOBALSIGN_CLIENT_CERT_PATH environment variable.
|
|
ClientCertPath string `json:"client_cert_path"`
|
|
|
|
// ClientKeyPath is the filesystem path to the mTLS client private key PEM file.
|
|
// Must match the certificate in ClientCertPath.
|
|
// Required. Set via CERTCTL_GLOBALSIGN_CLIENT_KEY_PATH environment variable.
|
|
ClientKeyPath string `json:"client_key_path"`
|
|
|
|
// ServerCAPath is the filesystem path to a PEM file containing the CA
|
|
// certificate(s) used to verify the GlobalSign Atlas HVCA API server certificate.
|
|
// Optional. If empty, the system trust store is used. This option exists for
|
|
// private/lab deployments of GlobalSign Atlas that terminate TLS with an
|
|
// internal CA not present in the host's default trust bundle.
|
|
// Set via CERTCTL_GLOBALSIGN_SERVER_CA_PATH environment variable.
|
|
ServerCAPath string `json:"server_ca_path,omitempty"`
|
|
|
|
// PollMaxWaitSeconds caps how long GetOrderStatus blocks doing
|
|
// internal exponential-backoff polling before returning
|
|
// StillPending. Default 600 (10 minutes). GlobalSign tracks
|
|
// orders by serial number rather than order ID, but the polling
|
|
// shape is identical.
|
|
//
|
|
// Set via CERTCTL_GLOBALSIGN_POLL_MAX_WAIT_SECONDS. Audit fix #5.
|
|
PollMaxWaitSeconds int `json:"poll_max_wait_seconds,omitempty"`
|
|
}
|
|
|
|
// pollMaxWait returns the configured PollMaxWait as a time.Duration,
|
|
// or the asyncpoll package default if unset.
|
|
func (c *Config) pollMaxWait() time.Duration {
|
|
if c.PollMaxWaitSeconds <= 0 {
|
|
return asyncpoll.DefaultMaxWait
|
|
}
|
|
return time.Duration(c.PollMaxWaitSeconds) * time.Second
|
|
}
|
|
|
|
// Connector implements the issuer.Connector interface for GlobalSign Atlas HVCA.
|
|
type Connector struct {
|
|
config *Config
|
|
logger *slog.Logger
|
|
httpClient *http.Client
|
|
|
|
// mtls caches the parsed client keypair + a precomputed
|
|
// *http.Transport so steady-state API calls don't re-parse the
|
|
// keypair on every request. Audit fix #10. nil in test mode
|
|
// (NewWithHTTPClient) and on the first ValidateConfig call
|
|
// before the cache is wired; getHTTPClient falls through to
|
|
// httpClient when nil so test paths keep their behaviour.
|
|
mtls *mtlscache.Cache
|
|
}
|
|
|
|
// New creates a new GlobalSign Atlas HVCA connector with the given configuration and logger.
|
|
// The connector will load the mTLS client certificate from the config paths on each API call.
|
|
func New(config *Config, logger *slog.Logger) *Connector {
|
|
return &Connector{
|
|
config: config,
|
|
logger: logger,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// NewWithHTTPClient creates a new GlobalSign connector with a custom HTTP client.
|
|
// Used for testing with mocked HTTP responses. The client is used directly instead of
|
|
// loading mTLS certificates, allowing tests to bypass TLS setup.
|
|
func NewWithHTTPClient(config *Config, logger *slog.Logger, client *http.Client) *Connector {
|
|
return &Connector{
|
|
config: config,
|
|
logger: logger,
|
|
httpClient: client,
|
|
}
|
|
}
|
|
|
|
// certificateRequest is the JSON body for GlobalSign certificate order submission.
|
|
type certificateRequest struct {
|
|
CSR string `json:"csr"`
|
|
SubjectDN subjectDNRequest `json:"subject_dn"`
|
|
SAN sanRequest `json:"san,omitempty"`
|
|
}
|
|
|
|
type subjectDNRequest struct {
|
|
CommonName string `json:"common_name"`
|
|
}
|
|
|
|
type sanRequest struct {
|
|
DNSNames []string `json:"dns_names,omitempty"`
|
|
}
|
|
|
|
// certificateResponse is the JSON response from a certificate order submission or retrieval.
|
|
type certificateResponse struct {
|
|
SerialNumber string `json:"serial_number"`
|
|
Status string `json:"status"`
|
|
Certificate string `json:"certificate,omitempty"`
|
|
Chain string `json:"chain,omitempty"`
|
|
IssuedAt string `json:"issued_at,omitempty"`
|
|
}
|
|
|
|
// ValidateConfig checks that the GlobalSign configuration is valid and mTLS connection works.
|
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
|
var cfg Config
|
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
|
return fmt.Errorf("invalid GlobalSign config: %w", err)
|
|
}
|
|
|
|
if cfg.APIUrl == "" {
|
|
return fmt.Errorf("GlobalSign api_url is required")
|
|
}
|
|
|
|
if cfg.APIKey.IsEmpty() {
|
|
return fmt.Errorf("GlobalSign api_key is required")
|
|
}
|
|
|
|
if cfg.APISecret.IsEmpty() {
|
|
return fmt.Errorf("GlobalSign api_secret is required")
|
|
}
|
|
|
|
if cfg.ClientCertPath == "" {
|
|
return fmt.Errorf("GlobalSign client_cert_path is required")
|
|
}
|
|
|
|
if cfg.ClientKeyPath == "" {
|
|
return fmt.Errorf("GlobalSign client_key_path is required")
|
|
}
|
|
|
|
// Load the client certificate and key for mTLS validation
|
|
cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath, cfg.ClientKeyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load GlobalSign client certificate: %w", err)
|
|
}
|
|
|
|
// Build a verifying mTLS TLS config. If ServerCAPath is set, that PEM
|
|
// bundle is used as the trust anchor for the server certificate;
|
|
// otherwise the system trust store is used. TLS 1.2 is the minimum.
|
|
tlsConfig, err := buildServerTLSConfig(&cfg, cert)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build GlobalSign TLS config: %w", err)
|
|
}
|
|
|
|
validationClient := &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: tlsConfig,
|
|
},
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
// Test API access via GET /v2/certificates (list, requires auth headers)
|
|
listURL := strings.TrimSuffix(cfg.APIUrl, "/") + "/v2/certificates"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create API test request: %w", err)
|
|
}
|
|
|
|
// Add both authentication layers
|
|
setAuthHeaders(req, &cfg)
|
|
|
|
resp, err := validationClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("GlobalSign API not reachable at %s: %w", cfg.APIUrl, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
|
|
return fmt.Errorf("GlobalSign API credentials are invalid (status %d)", resp.StatusCode)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("GlobalSign API returned status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
c.config = &cfg
|
|
c.logger.Info("GlobalSign Atlas HVCA configuration validated",
|
|
"api_url", cfg.APIUrl)
|
|
|
|
return nil
|
|
}
|
|
|
|
// getHTTPClient returns the HTTP client to use, creating one with mTLS if needed.
|
|
// If the connector was created with NewWithHTTPClient (test mode), uses that client directly.
|
|
// Otherwise, returns the cached mTLS client (audit fix #10), refreshing it
|
|
// from disk if the cert file's mtime has advanced since the last load —
|
|
// rotation-via-mv-f takes effect on the next call without a process restart.
|
|
func (c *Connector) getHTTPClient(ctx context.Context) (*http.Client, error) {
|
|
// Test mode: NewWithHTTPClient supplied a pre-built client with a
|
|
// non-nil Transport. The cache layer must NOT intercept this
|
|
// branch — tests need their httptest-backed transport, not an
|
|
// mTLS one against a (probably non-existent) cert file.
|
|
if c.httpClient != nil && c.httpClient.Transport != nil {
|
|
return c.httpClient, nil
|
|
}
|
|
|
|
// Test mode 2: bare default client + no cert paths configured.
|
|
// Same rationale — return what the caller supplied as-is.
|
|
if c.config.ClientCertPath == "" || c.config.ClientKeyPath == "" {
|
|
return c.httpClient, nil
|
|
}
|
|
|
|
// Production mode: lazy-build the cache on the first call so the
|
|
// constructor stays cheap (no disk I/O). Subsequent calls take
|
|
// the fast path through the cache's RWMutex.
|
|
if c.mtls == nil {
|
|
// Capture the config pointer so the TLSConfigBuilder closure
|
|
// reads the current ServerCAPath. The cache itself owns the
|
|
// rebuild on rotation.
|
|
cfg := c.config
|
|
cache, err := mtlscache.New(c.config.ClientCertPath, c.config.ClientKeyPath, mtlscache.Options{
|
|
TLSConfigBuilder: func(cert tls.Certificate) (*tls.Config, error) {
|
|
return buildServerTLSConfig(cfg, cert)
|
|
},
|
|
HTTPTimeout: 30 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load GlobalSign client certificate (mTLS cache build): %w", err)
|
|
}
|
|
c.mtls = cache
|
|
} else if err := c.mtls.RefreshIfStale(); err != nil {
|
|
// stat / parse failure on rotation should bubble up — a
|
|
// missing cert file is a real outage signal. The cache
|
|
// keeps serving the previous keypair on parse error
|
|
// because reload only commits on success, but stat error
|
|
// is surfaced to the caller.
|
|
return nil, fmt.Errorf("failed to refresh GlobalSign mTLS cache: %w", err)
|
|
}
|
|
|
|
return c.mtls.Client(), nil
|
|
}
|
|
|
|
// setAuthHeaders writes the GlobalSign double-auth headers (ApiKey,
|
|
// ApiSecret) plus Content-Type: application/json onto req. The secret
|
|
// values are pulled from the *secret.Ref via Use, which zero-fills the
|
|
// per-call buffer after the header string is set; the Ref's underlying
|
|
// bytes remain encrypted at rest. The Use return value is intentionally
|
|
// ignored — Set never errors and the only failure modes inside Use are
|
|
// nil-Ref / empty-Ref which the upstream IsEmpty validation has already
|
|
// excluded for production paths. ValidateConfig and the steady-state
|
|
// IssueCertificate / RevokeCertificate / pollCertificateOnce sites all
|
|
// route through here so any future header-shape change applies once.
|
|
//
|
|
// Audit fix #6 Phase 2.
|
|
func setAuthHeaders(req *http.Request, cfg *Config) {
|
|
if cfg.APIKey != nil {
|
|
_ = cfg.APIKey.Use(func(buf []byte) error {
|
|
req.Header.Set("ApiKey", string(buf))
|
|
return nil
|
|
})
|
|
}
|
|
if cfg.APISecret != nil {
|
|
_ = cfg.APISecret.Use(func(buf []byte) error {
|
|
req.Header.Set("ApiSecret", string(buf))
|
|
return nil
|
|
})
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
// buildServerTLSConfig returns a TLS configuration for the GlobalSign Atlas
|
|
// HVCA API client. It always verifies the server certificate. When
|
|
// cfg.ServerCAPath is set, the PEM bundle at that path is used as the
|
|
// trust anchor (enables pinning a private/lab CA); otherwise the host's
|
|
// system trust store is used. TLS 1.2 is the minimum protocol version.
|
|
//
|
|
// This helper is the single source of truth for both the ValidateConfig
|
|
// probe client and the steady-state getHTTPClient production client, so
|
|
// any future TLS policy change applies uniformly.
|
|
func buildServerTLSConfig(cfg *Config, clientCert tls.Certificate) (*tls.Config, error) {
|
|
tlsConfig := &tls.Config{
|
|
Certificates: []tls.Certificate{clientCert},
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
|
|
if cfg.ServerCAPath != "" {
|
|
caPEM, err := os.ReadFile(cfg.ServerCAPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read server CA bundle at %s: %w", cfg.ServerCAPath, err)
|
|
}
|
|
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caPEM) {
|
|
return nil, fmt.Errorf("no valid PEM certificates found in server CA bundle at %s", cfg.ServerCAPath)
|
|
}
|
|
|
|
tlsConfig.RootCAs = pool
|
|
}
|
|
|
|
return tlsConfig, nil
|
|
}
|
|
|
|
// IssueCertificate submits a certificate order to GlobalSign Atlas HVCA.
|
|
// Returns the serial number immediately; typically the cert is available within seconds (DV) to minutes (OV).
|
|
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
|
c.logger.Info("processing GlobalSign issuance request",
|
|
"common_name", request.CommonName,
|
|
"san_count", len(request.SANs))
|
|
|
|
client, err := c.getHTTPClient(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
certReq := certificateRequest{
|
|
CSR: request.CSRPEM,
|
|
SubjectDN: subjectDNRequest{
|
|
CommonName: request.CommonName,
|
|
},
|
|
}
|
|
|
|
if len(request.SANs) > 0 {
|
|
certReq.SAN = sanRequest{
|
|
DNSNames: request.SANs,
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(certReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal certificate request: %w", err)
|
|
}
|
|
|
|
certURL := strings.TrimSuffix(c.config.APIUrl, "/") + "/v2/certificates"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, certURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create certificate request: %w", err)
|
|
}
|
|
|
|
// Apply double auth: mTLS + headers
|
|
setAuthHeaders(req, c.config)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GlobalSign certificate request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read certificate response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
return nil, fmt.Errorf("GlobalSign certificate submission returned status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var certResp certificateResponse
|
|
if err := json.Unmarshal(respBody, &certResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse certificate response: %w", err)
|
|
}
|
|
|
|
c.logger.Info("GlobalSign certificate order submitted",
|
|
"serial", certResp.SerialNumber,
|
|
"status", certResp.Status)
|
|
|
|
// If certificate is available immediately, return it.
|
|
// Otherwise, return just the serial number for polling via GetOrderStatus.
|
|
if certResp.Status == "issued" && certResp.Certificate != "" {
|
|
notBefore, notAfter, err := parseCertDates(certResp.Certificate)
|
|
if err != nil {
|
|
c.logger.Warn("failed to parse certificate dates", "error", err)
|
|
}
|
|
|
|
return &issuer.IssuanceResult{
|
|
CertPEM: certResp.Certificate,
|
|
ChainPEM: certResp.Chain,
|
|
Serial: certResp.SerialNumber,
|
|
NotBefore: notBefore,
|
|
NotAfter: notAfter,
|
|
OrderID: certResp.SerialNumber,
|
|
}, nil
|
|
}
|
|
|
|
// Pending — return serial number as OrderID for polling
|
|
c.logger.Info("GlobalSign certificate order pending",
|
|
"serial", certResp.SerialNumber,
|
|
"status", certResp.Status)
|
|
|
|
return &issuer.IssuanceResult{
|
|
OrderID: certResp.SerialNumber,
|
|
}, nil
|
|
}
|
|
|
|
// RenewCertificate renews a certificate by submitting a new order.
|
|
// GlobalSign uses serial number polling, so renewal is treated as a new issuance.
|
|
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
|
c.logger.Info("processing GlobalSign renewal request",
|
|
"common_name", request.CommonName,
|
|
"san_count", len(request.SANs))
|
|
|
|
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
|
|
CommonName: request.CommonName,
|
|
SANs: request.SANs,
|
|
CSRPEM: request.CSRPEM,
|
|
EKUs: request.EKUs,
|
|
})
|
|
}
|
|
|
|
// RevokeCertificate revokes a certificate at GlobalSign Atlas HVCA.
|
|
// GlobalSign revocation does not require a reason code.
|
|
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
|
c.logger.Info("processing GlobalSign revocation request", "serial", request.Serial)
|
|
|
|
client, err := c.getHTTPClient(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// GlobalSign revocation endpoint: PUT /v2/certificates/{serial}/revoke
|
|
// No request body or reason code required.
|
|
revokeURL := strings.TrimSuffix(c.config.APIUrl, "/") + fmt.Sprintf("/v2/certificates/%s/revoke", request.Serial)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create revoke request: %w", err)
|
|
}
|
|
|
|
setAuthHeaders(req, c.config)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("GlobalSign revoke request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// GlobalSign returns 200 OK on successful revocation
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("GlobalSign revoke returned status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
c.logger.Info("GlobalSign certificate revoked", "serial", request.Serial)
|
|
return nil
|
|
}
|
|
|
|
// GetOrderStatus checks the status of a GlobalSign certificate order
|
|
// by serial number, using bounded internal polling (asyncpoll.Poll).
|
|
// One call blocks for up to PollMaxWait (default 10m) doing
|
|
// exponential backoff with jitter; returns Done with the cert,
|
|
// Failed with the rejection reason, or StillPending if the deadline
|
|
// expires (caller can re-invoke).
|
|
//
|
|
// Audit fix #5 Phase 2: previously each scheduler tick made one HTTP
|
|
// call against an unready order. GlobalSign tracks orders by serial
|
|
// number rather than order ID, but the polling shape is identical.
|
|
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
|
c.logger.Debug("checking GlobalSign certificate status", "serial", orderID)
|
|
|
|
var done *issuer.OrderStatus
|
|
var lastPendingMsg string
|
|
cfg := asyncpoll.Config{MaxWait: c.config.pollMaxWait()}
|
|
|
|
res, err := asyncpoll.Poll(ctx, cfg, func(ctx context.Context) (asyncpoll.Result, error) {
|
|
status, result, pollErr := c.pollCertificateOnce(ctx, orderID)
|
|
if status != nil {
|
|
switch result {
|
|
case asyncpoll.Done:
|
|
done = status
|
|
case asyncpoll.StillPending:
|
|
if status.Message != nil {
|
|
lastPendingMsg = *status.Message
|
|
}
|
|
}
|
|
}
|
|
return result, pollErr
|
|
})
|
|
|
|
now := time.Now()
|
|
switch res {
|
|
case asyncpoll.Done:
|
|
return done, nil
|
|
case asyncpoll.Failed:
|
|
return nil, err
|
|
default:
|
|
msg := lastPendingMsg
|
|
if msg == "" {
|
|
msg = fmt.Sprintf("certificate %s still pending after PollMaxWait", orderID)
|
|
}
|
|
return &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: "pending",
|
|
Message: &msg,
|
|
UpdatedAt: now,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// pollCertificateOnce makes one HTTP GET against the GlobalSign Atlas
|
|
// HVCA certificate status endpoint and translates the response into
|
|
// an asyncpoll.Result. 4xx (not 429) is permanent; 5xx / 429 / network
|
|
// is transient.
|
|
func (c *Connector) pollCertificateOnce(ctx context.Context, orderID string) (*issuer.OrderStatus, asyncpoll.Result, error) {
|
|
client, err := c.getHTTPClient(ctx)
|
|
if err != nil {
|
|
return nil, asyncpoll.Failed, err
|
|
}
|
|
|
|
statusURL := strings.TrimSuffix(c.config.APIUrl, "/") + fmt.Sprintf("/v2/certificates/%s", orderID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
|
if err != nil {
|
|
return nil, asyncpoll.Failed, fmt.Errorf("failed to create status request: %w", err)
|
|
}
|
|
|
|
setAuthHeaders(req, c.config)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, asyncpoll.StillPending, fmt.Errorf("GlobalSign status request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, asyncpoll.StillPending, fmt.Errorf("failed to read status response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
statusErr := fmt.Errorf("GlobalSign certificate status returned %d: %s", resp.StatusCode, string(respBody))
|
|
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
|
return nil, asyncpoll.StillPending, statusErr
|
|
}
|
|
return nil, asyncpoll.Failed, statusErr
|
|
}
|
|
|
|
var certResp certificateResponse
|
|
if err := json.Unmarshal(respBody, &certResp); err != nil {
|
|
return nil, asyncpoll.Failed, fmt.Errorf("failed to parse status response: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
switch certResp.Status {
|
|
case "issued":
|
|
if certResp.Certificate == "" {
|
|
return nil, asyncpoll.Failed, fmt.Errorf("certificate status is issued but certificate PEM is missing")
|
|
}
|
|
notBefore, notAfter, err := parseCertDates(certResp.Certificate)
|
|
if err != nil {
|
|
c.logger.Warn("failed to parse certificate dates", "error", err)
|
|
}
|
|
c.logger.Info("GlobalSign certificate ready", "serial", orderID)
|
|
return &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: "completed",
|
|
CertPEM: &certResp.Certificate,
|
|
ChainPEM: &certResp.Chain,
|
|
Serial: &certResp.SerialNumber,
|
|
NotBefore: ¬Before,
|
|
NotAfter: ¬After,
|
|
UpdatedAt: now,
|
|
}, asyncpoll.Done, nil
|
|
|
|
case "pending", "processing":
|
|
msg := fmt.Sprintf("certificate %s is %s", orderID, certResp.Status)
|
|
return &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: "pending",
|
|
Message: &msg,
|
|
UpdatedAt: now,
|
|
}, asyncpoll.StillPending, nil
|
|
|
|
case "rejected", "denied", "failed":
|
|
msg := fmt.Sprintf("certificate %s was %s", orderID, certResp.Status)
|
|
return &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: "failed",
|
|
Message: &msg,
|
|
UpdatedAt: now,
|
|
}, asyncpoll.Done, nil
|
|
|
|
default:
|
|
msg := fmt.Sprintf("unknown certificate status: %s", certResp.Status)
|
|
return &issuer.OrderStatus{
|
|
OrderID: orderID,
|
|
Status: "pending",
|
|
Message: &msg,
|
|
UpdatedAt: now,
|
|
}, asyncpoll.StillPending, nil
|
|
}
|
|
}
|
|
|
|
// parseCertDates extracts NotBefore and NotAfter from a PEM-encoded certificate.
|
|
func parseCertDates(certPEM string) (time.Time, time.Time, error) {
|
|
block, _ := pem.Decode([]byte(certPEM))
|
|
if block == nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("failed to decode certificate PEM")
|
|
}
|
|
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("failed to parse certificate: %w", err)
|
|
}
|
|
|
|
return cert.NotBefore, cert.NotAfter, nil
|
|
}
|
|
|
|
// GenerateCRL is not supported because GlobalSign manages CRL distribution.
|
|
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
|
return nil, fmt.Errorf("GlobalSign manages CRL distribution; use GlobalSign's CRL endpoints")
|
|
}
|
|
|
|
// SignOCSPResponse is not supported because GlobalSign manages OCSP.
|
|
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
|
return nil, fmt.Errorf("GlobalSign manages OCSP; use GlobalSign's OCSP responder")
|
|
}
|
|
|
|
// GetCACertPEM is not directly supported. GlobalSign intermediate certificates
|
|
// come with each certificate issuance as part of the chain response.
|
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
|
return "", fmt.Errorf("GlobalSign intermediate certificates are included with each issued certificate")
|
|
}
|
|
|
|
// GetRenewalInfo returns nil, nil as GlobalSign does not support ACME Renewal Information (ARI).
|
|
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// Ensure Connector implements the issuer.Connector interface.
|
|
var _ issuer.Connector = (*Connector)(nil)
|