mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:11:29 +00:00
7cb453a336
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
286 lines
8.7 KiB
Go
286 lines
8.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// verifyDeployment probes the live TLS endpoint for a deployment target and verifies
|
|
// that the deployed certificate matches what we expect.
|
|
//
|
|
// Parameters:
|
|
// - targetHost: the hostname or IP of the target (extracted from target config)
|
|
// - targetPort: the TLS port of the target (e.g., 443)
|
|
// - expectedCertPEM: the PEM-encoded certificate that was deployed
|
|
// - delay: wait time before probing (e.g., 2 seconds for reload to take effect)
|
|
// - timeout: overall timeout for TLS connection attempt (e.g., 10 seconds)
|
|
//
|
|
// Returns:
|
|
// - A VerificationResult if probing succeeded (even if cert doesn't match)
|
|
// - An error if the probe itself failed (network error, timeout, etc.)
|
|
//
|
|
// The function compares the SHA-256 fingerprints of the expected and actual certificates.
|
|
// If the certificate served at the endpoint differs, Verified will be false but no error
|
|
// is returned — this is an expected verification failure, not a probe failure.
|
|
func verifyDeployment(
|
|
ctx context.Context,
|
|
targetHost string,
|
|
targetPort int,
|
|
expectedCertPEM string,
|
|
delay time.Duration,
|
|
timeout time.Duration,
|
|
logger *slog.Logger,
|
|
) (*VerificationResult, error) {
|
|
// Wait for reload to take effect
|
|
if delay > 0 {
|
|
select {
|
|
case <-time.After(delay):
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Parse expected certificate to compute its fingerprint
|
|
expectedFp, err := computeCertificateFingerprint(expectedCertPEM)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse expected certificate: %w", err)
|
|
}
|
|
|
|
// Connect to the target's TLS endpoint
|
|
address := fmt.Sprintf("%s:%d", targetHost, targetPort)
|
|
if logger != nil {
|
|
logger.Debug("probing TLS endpoint for verification",
|
|
"address", address,
|
|
"expected_fingerprint", expectedFp)
|
|
}
|
|
|
|
dialer := &net.Dialer{Timeout: timeout}
|
|
conn, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
|
|
// SECURITY NOTE: InsecureSkipVerify is intentionally set to true here.
|
|
// Post-deployment verification must probe the live endpoint to extract and
|
|
// compare the served certificate fingerprint, regardless of its validity
|
|
// state (expired, self-signed, internal CA, etc.). This setting is scoped
|
|
// to verification probing only — it is NEVER used for control-plane API
|
|
// calls, issuer connector communication, or any operation that trusts the
|
|
// certificate. The verification result compares SHA-256 fingerprints only.
|
|
// See TICKET-016 for full security audit rationale.
|
|
InsecureSkipVerify: true, //nolint:gosec // verification probe; documented above + docs/tls.md L-001 table
|
|
ServerName: targetHost, // For SNI
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to %s: %w", address, err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Extract the leaf certificate from the TLS connection
|
|
state := conn.ConnectionState()
|
|
if len(state.PeerCertificates) == 0 {
|
|
return nil, fmt.Errorf("no certificates presented by %s", address)
|
|
}
|
|
|
|
leafCert := state.PeerCertificates[0]
|
|
actualFp := fmt.Sprintf("%x", sha256.Sum256(leafCert.Raw))
|
|
|
|
if logger != nil {
|
|
logger.Debug("received certificate from endpoint",
|
|
"address", address,
|
|
"cn", leafCert.Subject.CommonName,
|
|
"actual_fingerprint", actualFp)
|
|
}
|
|
|
|
// Compare fingerprints
|
|
verified := actualFp == expectedFp
|
|
if logger != nil {
|
|
if !verified {
|
|
logger.Warn("certificate fingerprint mismatch at endpoint",
|
|
"address", address,
|
|
"expected_fingerprint", expectedFp,
|
|
"actual_fingerprint", actualFp)
|
|
} else {
|
|
logger.Info("certificate verification succeeded",
|
|
"address", address,
|
|
"fingerprint", actualFp)
|
|
}
|
|
}
|
|
|
|
return &VerificationResult{
|
|
ExpectedFingerprint: expectedFp,
|
|
ActualFingerprint: actualFp,
|
|
Verified: verified,
|
|
VerifiedAt: time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
|
|
// VerificationResult represents the outcome of verifying a deployed certificate.
|
|
type VerificationResult struct {
|
|
ExpectedFingerprint string `json:"expected_fingerprint"`
|
|
ActualFingerprint string `json:"actual_fingerprint"`
|
|
Verified bool `json:"verified"`
|
|
VerifiedAt time.Time `json:"verified_at"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// computeCertificateFingerprint computes the SHA-256 fingerprint of a PEM-encoded certificate.
|
|
func computeCertificateFingerprint(certPEM string) (string, error) {
|
|
block, _ := pem.Decode([]byte(certPEM))
|
|
if block == nil {
|
|
return "", fmt.Errorf("failed to decode PEM certificate")
|
|
}
|
|
|
|
cert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse x509 certificate: %w", err)
|
|
}
|
|
|
|
fp := sha256.Sum256(cert.Raw)
|
|
return fmt.Sprintf("%x", fp), nil
|
|
}
|
|
|
|
// reportVerificationResult submits the verification result back to the control plane.
|
|
// This is a best-effort operation — a failure to report doesn't block agent progress.
|
|
func (a *Agent) reportVerificationResult(
|
|
ctx context.Context,
|
|
jobID string,
|
|
targetID string,
|
|
result *VerificationResult,
|
|
) error {
|
|
if jobID == "" || targetID == "" || result == nil {
|
|
return fmt.Errorf("missing required fields for verification report")
|
|
}
|
|
|
|
// Build the request payload
|
|
payload := map[string]interface{}{
|
|
"target_id": targetID,
|
|
"expected_fingerprint": result.ExpectedFingerprint,
|
|
"actual_fingerprint": result.ActualFingerprint,
|
|
"verified": result.Verified,
|
|
"error": result.Error,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal verification result: %w", err)
|
|
}
|
|
|
|
// POST to /api/v1/jobs/{id}/verify
|
|
url := fmt.Sprintf("%s/api/v1/jobs/%s/verify", a.config.ServerURL, jobID)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create verification request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.config.APIKey))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := a.client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send verification result: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check response status
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("verification reporting failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
if a.logger != nil {
|
|
a.logger.Debug("verification result reported to control plane",
|
|
"job_id", jobID,
|
|
"verified", result.Verified)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractTargetHostAndPort extracts the host and port from target configuration.
|
|
// Common target configs include "host" or "hostname" and "port" fields.
|
|
func extractTargetHostAndPort(configJSON json.RawMessage) (string, int, error) {
|
|
var config map[string]interface{}
|
|
if err := json.Unmarshal(configJSON, &config); err != nil {
|
|
return "", 0, fmt.Errorf("invalid target config JSON: %w", err)
|
|
}
|
|
|
|
// Try common field names for hostname
|
|
var host string
|
|
for _, key := range []string{"host", "hostname", "target", "address"} {
|
|
if h, ok := config[key].(string); ok && h != "" {
|
|
host = h
|
|
break
|
|
}
|
|
}
|
|
if host == "" {
|
|
return "", 0, fmt.Errorf("target config missing host/hostname field")
|
|
}
|
|
|
|
// Try common field names for port, default to 443
|
|
port := 443
|
|
if p, ok := config["port"].(float64); ok {
|
|
port = int(p)
|
|
}
|
|
if port < 1 || port > 65535 {
|
|
return "", 0, fmt.Errorf("invalid port: %d", port)
|
|
}
|
|
|
|
return host, port, nil
|
|
}
|
|
|
|
// verifyAndReportDeployment performs TLS endpoint verification and reports the result.
|
|
// This is a best-effort operation — failures are logged but don't affect deployment status.
|
|
func (a *Agent) verifyAndReportDeployment(
|
|
ctx context.Context,
|
|
job JobItem,
|
|
targetHost string,
|
|
targetPort int,
|
|
certPEM string,
|
|
) {
|
|
// Perform verification with configured timeout and delay
|
|
result, err := verifyDeployment(ctx, targetHost, targetPort, certPEM,
|
|
2*time.Second, // delay before probing
|
|
10*time.Second, // timeout for TLS connection
|
|
a.logger)
|
|
|
|
if err != nil {
|
|
if a.logger != nil {
|
|
a.logger.Warn("verification probe failed",
|
|
"job_id", job.ID,
|
|
"target_host", targetHost,
|
|
"target_port", targetPort,
|
|
"error", err)
|
|
}
|
|
// Probe failure: report error but continue
|
|
result = &VerificationResult{
|
|
Error: err.Error(),
|
|
VerifiedAt: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
// Report result to control plane
|
|
if job.TargetID == nil {
|
|
if a.logger != nil {
|
|
a.logger.Warn("cannot report verification: target_id is nil", "job_id", job.ID)
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := a.reportVerificationResult(ctx, job.ID, *job.TargetID, result); err != nil {
|
|
if a.logger != nil {
|
|
a.logger.Warn("failed to report verification result",
|
|
"job_id", job.ID,
|
|
"error", err)
|
|
}
|
|
// Non-blocking: continue even if report fails
|
|
}
|
|
}
|