mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
d60a0ac297
Bundle 1 closure (2026-05-12 acquisition diligence audit). Closes the
acquisition-blocker chain: target.edit (default r-operator grant per
migrations/000029_rbac.up.sql:196) → arbitrary reload_command stored
without validation → agent createTargetConnector json.Unmarshal-only
→ sh -c on agent host. README's 'shell injection prevention on all
connector scripts' claim is now true at the chain level.
Server-side: new internal/connector/target/configcheck package + a
configcheck.Validate call in target.go::Create + ::Update +
::CreateTarget + ::UpdateTarget (all 4 entry points). Rejects shell
metacharacters in reload_command / validate_command / restart_command
for nginx, apache, haproxy, postfix/dovecot, javakeystore, ssh. Sentinel
errors.Is(err, service.ErrInvalidConnectorConfig) available for handler
400 mapping. Non-shell connector types (F5, IIS, Caddy, Traefik, Envoy,
cloud targets, K8s) are no-ops by design.
Agent-side: defense-in-depth connector.ValidateConfig(ctx, configJSON)
call in cmd/agent/main.go inserted between createTargetConnector and
DeployCertificate. This catches (a) configs pre-dating the server gate,
(b) encrypted-blob tampering, (c) per-connector filesystem invariants
that the server can't check.
F5 (S2 finding): proven docs-vs-code drift, not a security bug. The
applyDefaults function never set Insecure=true; runtime default has
always been Go zero-value (false → TLS verified). Three lying 'default
true' comments in f5/f5.go (lines 30, 45-47, 126) rewritten to match
actual code behavior.
Docs (C4 + C9): README L12 + L68 narrowed — 'any CA / any server' →
'Twelve native CA connectors plus an OpenSSL adapter; fifteen native
deployment-target connectors plus a proxy-agent pattern.' 'Every deploy
goes through atomic-write + ...' narrowed to file-based connectors with
inline link to per-target guarantee matrix. New deployment-model.md §1.6
ships a 15-target × 8-property guarantee table covering atomic write /
owner-perms / SHA-256 idempotency / pre-deploy snapshot / on-failure
rollback / post-deploy TLS verify / Prometheus counters / shell-injection
validation — including the K8s preview honesty marker (CLAIM-H4).
Tests: internal/connector/target/configcheck/configcheck_test.go covers
14 shell-injection payloads (semicolon, pipe, backtick, dollar-paren,
redirect, and-chain, newline, double-quote, escape, dollar-var) × 7
shell-using connectors + benign-command acceptance + non-shell no-op
behavior + empty config + malformed JSON. All pass.
Verification (run from /sessions/gifted-blissful-pasteur/mnt/cowork/certctl):
go fmt ./... # clean (no diffs)
go vet ./... # clean (no findings)
go test -short -count=1 ./internal/... ./cmd/...
# 60+ packages all ok, zero FAIL
Audit-Closes: BUNDLE-1 RT-C1 SEC-M4 CLAIM-M2 CLAIM-L3
Audit-Verifies-False: S2 (F5 'default insecure' was a comment lie, code was always secure)
898 lines
31 KiB
Go
898 lines
31 KiB
Go
package f5
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/connector/target"
|
|
)
|
|
|
|
// Config represents the F5 BIG-IP deployment target configuration.
|
|
// Credentials are stored on the proxy agent, not on the control plane server,
|
|
// limiting the credential blast radius to the proxy agent's network zone.
|
|
type Config struct {
|
|
Host string `json:"host"` // F5 BIG-IP management hostname or IP
|
|
Port int `json:"port"` // Management port (default 443)
|
|
Username string `json:"username"` // Administrative username
|
|
Password string `json:"password"` // Administrative password
|
|
Partition string `json:"partition"` // F5 partition name (default "Common")
|
|
SSLProfile string `json:"ssl_profile"` // SSL client profile name to update
|
|
Insecure bool `json:"insecure"` // Skip TLS verification for mgmt interface. Default false (TLS verified). Operators with self-signed F5 mgmt certs must explicitly set "insecure": true.
|
|
Timeout int `json:"timeout"` // HTTP timeout in seconds (default 30)
|
|
}
|
|
|
|
// applyDefaults fills in zero-value fields with sensible defaults.
|
|
func (c *Config) applyDefaults() {
|
|
if c.Port == 0 {
|
|
c.Port = 443
|
|
}
|
|
if c.Partition == "" {
|
|
c.Partition = "Common"
|
|
}
|
|
if c.Timeout == 0 {
|
|
c.Timeout = 30
|
|
}
|
|
// Insecure has no override in applyDefaults — runtime default is the Go
|
|
// zero-value (false), which means InsecureSkipVerify=false and TLS is
|
|
// VERIFIED. Operators with self-signed F5 management certs must opt in
|
|
// by passing "insecure": true explicitly in the connector config. The
|
|
// `S2 (F5 insecure-by-default)` finding from the 2026-05-12 audit was
|
|
// based on a misleading legacy comment, not actual code behavior — this
|
|
// closure (Bundle 1 / 2026-05-12) corrects the comments to match the
|
|
// code default. See TICKET-016 precedent for InsecureSkipVerify framing.
|
|
}
|
|
|
|
// SSLProfileInfo contains information about an F5 SSL client profile.
|
|
type SSLProfileInfo struct {
|
|
Name string `json:"name"`
|
|
Cert string `json:"cert"`
|
|
Key string `json:"key"`
|
|
Chain string `json:"chain"`
|
|
}
|
|
|
|
// F5Client abstracts iControl REST API calls for testability.
|
|
// The real implementation uses net/http against the F5 management interface.
|
|
// Tests inject a mock implementation to verify call sequences without a real F5.
|
|
type F5Client interface {
|
|
// Authenticate obtains an auth token from the F5. Implementations should
|
|
// cache the token and re-authenticate on 401.
|
|
Authenticate(ctx context.Context) error
|
|
|
|
// UploadFile uploads raw bytes to the F5 file transfer endpoint.
|
|
// The Content-Range header is required even for single-chunk uploads.
|
|
UploadFile(ctx context.Context, filename string, data []byte) error
|
|
|
|
// InstallCert installs an uploaded file as a crypto cert object.
|
|
InstallCert(ctx context.Context, name, localFile string) error
|
|
|
|
// InstallKey installs an uploaded file as a crypto key object.
|
|
InstallKey(ctx context.Context, name, localFile string) error
|
|
|
|
// CreateTransaction starts an F5 transaction for atomic operations.
|
|
// Returns the transaction ID.
|
|
CreateTransaction(ctx context.Context) (string, error)
|
|
|
|
// CommitTransaction commits a transaction. If the commit fails,
|
|
// F5 rolls back all operations within the transaction automatically.
|
|
CommitTransaction(ctx context.Context, transID string) error
|
|
|
|
// UpdateSSLProfile updates an SSL client profile's cert, key, and chain
|
|
// references. If transID is non-empty, the operation is performed within
|
|
// the given transaction.
|
|
UpdateSSLProfile(ctx context.Context, partition, profile string, certName, keyName, chainName string, transID string) error
|
|
|
|
// GetSSLProfile retrieves the current configuration of an SSL client profile.
|
|
GetSSLProfile(ctx context.Context, partition, profile string) (*SSLProfileInfo, error)
|
|
|
|
// DeleteCert removes a crypto cert object from the F5.
|
|
DeleteCert(ctx context.Context, partition, name string) error
|
|
|
|
// DeleteKey removes a crypto key object from the F5.
|
|
DeleteKey(ctx context.Context, partition, name string) error
|
|
}
|
|
|
|
// Connector implements the target.Connector interface for F5 BIG-IP load balancers.
|
|
// This connector communicates with F5's iControl REST API to upload certificates,
|
|
// manage SSL profiles, and validate deployments. It uses the proxy agent pattern:
|
|
// a designated agent in the same network zone polls for F5 deployment jobs and
|
|
// executes iControl REST calls on behalf of the control plane.
|
|
//
|
|
// Minimum supported BIG-IP version: 12.0+.
|
|
type Connector struct {
|
|
config *Config
|
|
logger *slog.Logger
|
|
client F5Client
|
|
}
|
|
|
|
// New creates a new F5 target connector with the given configuration and logger.
|
|
// The real iControl REST HTTP client is initialized with TLS settings based on config.
|
|
func New(config *Config, logger *slog.Logger) (*Connector, error) {
|
|
if config == nil {
|
|
return nil, fmt.Errorf("F5 config is required")
|
|
}
|
|
config.applyDefaults()
|
|
|
|
httpClient := &http.Client{
|
|
Timeout: time.Duration(config.Timeout) * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
// F5 management interfaces commonly use self-signed certificates.
|
|
// InsecureSkipVerify is controlled by config.Insecure (default
|
|
// false → TLS verified). Operators with self-signed mgmt certs
|
|
// must opt in by passing "insecure": true in the connector
|
|
// config. See TICKET-016 for security rationale.
|
|
InsecureSkipVerify: config.Insecure, //nolint:gosec // configurable, documented
|
|
},
|
|
},
|
|
}
|
|
|
|
realClient := &realF5Client{
|
|
baseURL: fmt.Sprintf("https://%s:%d", config.Host, config.Port),
|
|
username: config.Username,
|
|
password: config.Password,
|
|
httpClient: httpClient,
|
|
logger: logger,
|
|
}
|
|
|
|
return &Connector{
|
|
config: config,
|
|
logger: logger,
|
|
client: realClient,
|
|
}, nil
|
|
}
|
|
|
|
// NewWithClient creates a new F5 target connector with an injected F5Client.
|
|
// Used in tests to mock iControl REST API calls without a real F5 device.
|
|
func NewWithClient(config *Config, logger *slog.Logger, client F5Client) *Connector {
|
|
if config != nil {
|
|
config.applyDefaults()
|
|
}
|
|
return &Connector{
|
|
config: config,
|
|
logger: logger,
|
|
client: client,
|
|
}
|
|
}
|
|
|
|
// Regex validators for config fields to prevent injection.
|
|
// Same pattern as IIS validIISName.
|
|
var (
|
|
// validHost matches hostnames, IPv4, and IPv6 addresses.
|
|
validHost = regexp.MustCompile(`^[a-zA-Z0-9\.\-\:\[\]]+$`)
|
|
|
|
// validPartition matches F5 partition names (alphanumeric, underscore, hyphen).
|
|
validPartition = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`)
|
|
|
|
// validProfileName matches SSL profile names (alphanumeric, underscore, hyphen, dot).
|
|
validProfileName = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`)
|
|
)
|
|
|
|
// ValidateConfig checks that the F5 BIG-IP is reachable and credentials are valid.
|
|
// It validates config fields, applies defaults, and tests authentication.
|
|
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 F5 config: %w", err)
|
|
}
|
|
|
|
// Validate required fields
|
|
if cfg.Host == "" {
|
|
return fmt.Errorf("host is required")
|
|
}
|
|
if cfg.Username == "" {
|
|
return fmt.Errorf("username is required")
|
|
}
|
|
if cfg.Password == "" {
|
|
return fmt.Errorf("password is required")
|
|
}
|
|
if cfg.SSLProfile == "" {
|
|
return fmt.Errorf("ssl_profile is required")
|
|
}
|
|
|
|
cfg.applyDefaults()
|
|
|
|
// Validate field formats (prevent injection)
|
|
if !validHost.MatchString(cfg.Host) {
|
|
return fmt.Errorf("host contains invalid characters (allowed: alphanumeric, dots, hyphens, colons, brackets)")
|
|
}
|
|
if len(cfg.Host) > 253 {
|
|
return fmt.Errorf("host exceeds maximum length (253 characters)")
|
|
}
|
|
if !validPartition.MatchString(cfg.Partition) {
|
|
return fmt.Errorf("partition contains invalid characters (allowed: alphanumeric, underscore, hyphen)")
|
|
}
|
|
if len(cfg.Partition) > 64 {
|
|
return fmt.Errorf("partition exceeds maximum length (64 characters)")
|
|
}
|
|
if !validProfileName.MatchString(cfg.SSLProfile) {
|
|
return fmt.Errorf("ssl_profile contains invalid characters (allowed: alphanumeric, underscore, hyphen, dot)")
|
|
}
|
|
if len(cfg.SSLProfile) > 256 {
|
|
return fmt.Errorf("ssl_profile exceeds maximum length (256 characters)")
|
|
}
|
|
|
|
// Validate port range
|
|
if cfg.Port < 1 || cfg.Port > 65535 {
|
|
return fmt.Errorf("port must be between 1 and 65535, got %d", cfg.Port)
|
|
}
|
|
|
|
c.logger.Info("validating F5 configuration",
|
|
"host", cfg.Host,
|
|
"port", cfg.Port,
|
|
"partition", cfg.Partition,
|
|
"ssl_profile", cfg.SSLProfile)
|
|
|
|
// Test authentication
|
|
if err := c.client.Authenticate(ctx); err != nil {
|
|
return fmt.Errorf("F5 authentication failed: %w", err)
|
|
}
|
|
|
|
c.config = &cfg
|
|
c.logger.Info("F5 configuration validated",
|
|
"host", cfg.Host,
|
|
"partition", cfg.Partition,
|
|
"ssl_profile", cfg.SSLProfile)
|
|
|
|
return nil
|
|
}
|
|
|
|
// objectName generates a unique name for F5 crypto objects using nanosecond timestamps.
|
|
// Format: certctl-{type}-{unix_nanos}
|
|
func objectName(objType string) string {
|
|
return fmt.Sprintf("certctl-%s-%d", objType, time.Now().UnixNano())
|
|
}
|
|
|
|
// partitionPath returns the full partition-qualified path for an F5 object reference.
|
|
// Used in JSON body values (e.g., "/Common/certctl-cert-xxx").
|
|
func partitionPath(partition, name string) string {
|
|
return fmt.Sprintf("/%s/%s", partition, name)
|
|
}
|
|
|
|
// DeployCertificate uploads a certificate to the F5 BIG-IP and updates the specified SSL profile.
|
|
//
|
|
// The deployment uses F5's transaction API for atomic profile updates:
|
|
// 1. Authenticate to iControl REST API
|
|
// 2. Upload cert/key/chain PEM files via file transfer endpoint
|
|
// 3. Install as crypto objects (cert, key, optionally chain)
|
|
// 4. Create a transaction
|
|
// 5. Update SSL profile within the transaction
|
|
// 6. Commit the transaction (atomic — rolls back on failure)
|
|
//
|
|
// On failure after crypto object installation, cleanup removes uploaded objects
|
|
// to avoid accumulating orphans on the F5.
|
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
|
c.logger.Info("deploying certificate to F5 BIG-IP",
|
|
"host", c.config.Host,
|
|
"partition", c.config.Partition,
|
|
"ssl_profile", c.config.SSLProfile)
|
|
|
|
startTime := time.Now()
|
|
|
|
// Validate we have a private key
|
|
if request.KeyPEM == "" {
|
|
errMsg := "private key (KeyPEM) is required for F5 deployment"
|
|
c.logger.Error("deployment failed", "error", errMsg)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Step 1: Authenticate
|
|
if err := c.client.Authenticate(ctx); err != nil {
|
|
errMsg := fmt.Sprintf("F5 authentication failed: %v", err)
|
|
c.logger.Error("deployment failed", "error", err)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Generate unique object names
|
|
certName := objectName("cert")
|
|
keyName := objectName("key")
|
|
chainName := ""
|
|
hasChain := strings.TrimSpace(request.ChainPEM) != ""
|
|
if hasChain {
|
|
chainName = objectName("chain")
|
|
}
|
|
|
|
// Track installed objects for cleanup on failure
|
|
var installedCerts []string
|
|
var installedKeys []string
|
|
|
|
cleanup := func() {
|
|
c.cleanupCryptoObjects(ctx, c.config.Partition, installedCerts, installedKeys)
|
|
}
|
|
|
|
// Step 2-3: Upload cert and key PEM files
|
|
certFilename := certName + ".pem"
|
|
if err := c.client.UploadFile(ctx, certFilename, []byte(request.CertPEM)); err != nil {
|
|
errMsg := fmt.Sprintf("failed to upload certificate file: %v", err)
|
|
c.logger.Error("cert upload failed", "error", err)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
keyFilename := keyName + ".pem"
|
|
if err := c.client.UploadFile(ctx, keyFilename, []byte(request.KeyPEM)); err != nil {
|
|
errMsg := fmt.Sprintf("failed to upload key file: %v", err)
|
|
c.logger.Error("key upload failed", "error", err)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Step 4: Upload chain if present
|
|
chainFilename := ""
|
|
if hasChain {
|
|
chainFilename = chainName + ".pem"
|
|
if err := c.client.UploadFile(ctx, chainFilename, []byte(request.ChainPEM)); err != nil {
|
|
errMsg := fmt.Sprintf("failed to upload chain file: %v", err)
|
|
c.logger.Error("chain upload failed", "error", err)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
|
|
// Step 5: Install cert crypto object
|
|
certLocalFile := "/var/config/rest/downloads/" + certFilename
|
|
if err := c.client.InstallCert(ctx, certName, certLocalFile); err != nil {
|
|
errMsg := fmt.Sprintf("failed to install cert crypto object: %v", err)
|
|
c.logger.Error("cert install failed", "error", err)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
installedCerts = append(installedCerts, certName)
|
|
|
|
// Step 6: Install key crypto object
|
|
keyLocalFile := "/var/config/rest/downloads/" + keyFilename
|
|
if err := c.client.InstallKey(ctx, keyName, keyLocalFile); err != nil {
|
|
errMsg := fmt.Sprintf("failed to install key crypto object: %v", err)
|
|
c.logger.Error("key install failed", "error", err)
|
|
cleanup()
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
installedKeys = append(installedKeys, keyName)
|
|
|
|
// Step 7: Install chain crypto object (if present)
|
|
if hasChain {
|
|
chainLocalFile := "/var/config/rest/downloads/" + chainFilename
|
|
if err := c.client.InstallCert(ctx, chainName, chainLocalFile); err != nil {
|
|
errMsg := fmt.Sprintf("failed to install chain crypto object: %v", err)
|
|
c.logger.Error("chain install failed", "error", err)
|
|
cleanup()
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
installedCerts = append(installedCerts, chainName)
|
|
}
|
|
|
|
// Step 8: Create transaction for atomic SSL profile update
|
|
transID, err := c.client.CreateTransaction(ctx)
|
|
if err != nil {
|
|
errMsg := fmt.Sprintf("failed to create F5 transaction: %v", err)
|
|
c.logger.Error("transaction creation failed", "error", err)
|
|
cleanup()
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Step 9: Update SSL profile within transaction
|
|
profileChainName := chainName
|
|
if err := c.client.UpdateSSLProfile(ctx, c.config.Partition, c.config.SSLProfile, certName, keyName, profileChainName, transID); err != nil {
|
|
errMsg := fmt.Sprintf("failed to update SSL profile: %v", err)
|
|
c.logger.Error("profile update failed", "error", err,
|
|
"ssl_profile", c.config.SSLProfile,
|
|
"transaction_id", transID)
|
|
cleanup()
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Step 10: Commit transaction
|
|
if err := c.client.CommitTransaction(ctx, transID); err != nil {
|
|
errMsg := fmt.Sprintf("failed to commit F5 transaction: %v", err)
|
|
c.logger.Error("transaction commit failed", "error", err,
|
|
"transaction_id", transID)
|
|
cleanup()
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
deploymentDuration := time.Since(startTime)
|
|
c.logger.Info("certificate deployed to F5 BIG-IP successfully",
|
|
"duration", deploymentDuration.String(),
|
|
"host", c.config.Host,
|
|
"ssl_profile", c.config.SSLProfile,
|
|
"cert_object", certName)
|
|
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
DeploymentID: fmt.Sprintf("f5-%s-%d", certName, time.Now().Unix()),
|
|
Message: "Certificate uploaded and SSL profile updated via iControl REST",
|
|
DeployedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"host": c.config.Host,
|
|
"partition": c.config.Partition,
|
|
"ssl_profile": c.config.SSLProfile,
|
|
"cert_object_name": certName,
|
|
"key_object_name": keyName,
|
|
"chain_object_name": chainName,
|
|
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// cleanupCryptoObjects removes installed crypto objects from the F5 on deployment failure.
|
|
// Best-effort: logs warnings on cleanup failures but does not mask the original error.
|
|
func (c *Connector) cleanupCryptoObjects(ctx context.Context, partition string, certNames, keyNames []string) {
|
|
for _, name := range certNames {
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if err := c.client.DeleteCert(ctx, partition, name); err != nil {
|
|
c.logger.Warn("cleanup: failed to delete cert crypto object",
|
|
"name", name, "partition", partition, "error", err)
|
|
} else {
|
|
c.logger.Debug("cleanup: deleted cert crypto object",
|
|
"name", name, "partition", partition)
|
|
}
|
|
}
|
|
for _, name := range keyNames {
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if err := c.client.DeleteKey(ctx, partition, name); err != nil {
|
|
c.logger.Warn("cleanup: failed to delete key crypto object",
|
|
"name", name, "partition", partition, "error", err)
|
|
} else {
|
|
c.logger.Debug("cleanup: deleted key crypto object",
|
|
"name", name, "partition", partition)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ValidateDeployment verifies that the certificate is properly deployed on the F5 BIG-IP.
|
|
// It queries the SSL profile and checks that it references a certctl-managed certificate.
|
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
|
c.logger.Info("validating F5 deployment",
|
|
"certificate_id", request.CertificateID,
|
|
"serial", request.Serial,
|
|
"ssl_profile", c.config.SSLProfile)
|
|
|
|
startTime := time.Now()
|
|
|
|
// Authenticate
|
|
if err := c.client.Authenticate(ctx); err != nil {
|
|
errMsg := fmt.Sprintf("F5 authentication failed: %v", err)
|
|
c.logger.Error("validation failed", "error", err)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Query SSL profile
|
|
profile, err := c.client.GetSSLProfile(ctx, c.config.Partition, c.config.SSLProfile)
|
|
if err != nil {
|
|
errMsg := fmt.Sprintf("failed to get SSL profile %q: %v", c.config.SSLProfile, err)
|
|
c.logger.Error("validation failed", "error", err,
|
|
"ssl_profile", c.config.SSLProfile)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
// Verify profile has a cert configured
|
|
if profile.Cert == "" {
|
|
errMsg := fmt.Sprintf("SSL profile %q has no certificate configured", c.config.SSLProfile)
|
|
c.logger.Error("validation failed", "error", errMsg)
|
|
return &target.ValidationResult{
|
|
Valid: false,
|
|
Serial: request.Serial,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: errMsg,
|
|
ValidatedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
|
|
validationDuration := time.Since(startTime)
|
|
c.logger.Info("F5 deployment validated",
|
|
"duration", validationDuration.String(),
|
|
"ssl_profile", c.config.SSLProfile,
|
|
"current_cert", profile.Cert)
|
|
|
|
return &target.ValidationResult{
|
|
Valid: true,
|
|
Serial: request.Serial,
|
|
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
|
Message: fmt.Sprintf("SSL profile %q has cert %q configured", c.config.SSLProfile, profile.Cert),
|
|
ValidatedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"host": c.config.Host,
|
|
"ssl_profile": c.config.SSLProfile,
|
|
"current_cert": profile.Cert,
|
|
"current_key": profile.Key,
|
|
"current_chain": profile.Chain,
|
|
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// --- realF5Client: production iControl REST implementation ---
|
|
|
|
// realF5Client implements F5Client using net/http against the iControl REST API.
|
|
type realF5Client struct {
|
|
baseURL string
|
|
username string
|
|
password string
|
|
httpClient *http.Client
|
|
logger *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
token string
|
|
}
|
|
|
|
// Authenticate obtains a token from POST /mgmt/shared/authn/login.
|
|
// The token is cached and reused. On 401 errors in other methods,
|
|
// callers should call Authenticate again to refresh.
|
|
func (c *realF5Client) Authenticate(ctx context.Context) error {
|
|
body := map[string]string{
|
|
"username": c.username,
|
|
"password": c.password,
|
|
"loginProviderName": "tmos",
|
|
}
|
|
bodyJSON, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal auth body: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/mgmt/shared/authn/login", bytes.NewReader(bodyJSON))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create auth request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("F5 auth request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("F5 auth failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result struct {
|
|
Token struct {
|
|
Token string `json:"token"`
|
|
} `json:"token"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return fmt.Errorf("failed to decode auth response: %w", err)
|
|
}
|
|
if result.Token.Token == "" {
|
|
return fmt.Errorf("F5 auth response contained no token")
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.token = result.Token.Token
|
|
c.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// doRequest executes an HTTP request with the F5 auth token.
|
|
// On 401 response, it re-authenticates once and retries.
|
|
func (c *realF5Client) doRequest(ctx context.Context, method, url string, body io.Reader, extraHeaders map[string]string) (*http.Response, error) {
|
|
return c.doRequestInternal(ctx, method, url, body, extraHeaders, true)
|
|
}
|
|
|
|
func (c *realF5Client) doRequestInternal(ctx context.Context, method, url string, body io.Reader, extraHeaders map[string]string, retryOn401 bool) (*http.Response, error) {
|
|
// Buffer body for potential retry
|
|
var bodyBytes []byte
|
|
if body != nil {
|
|
var err error
|
|
bodyBytes, err = io.ReadAll(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read request body: %w", err)
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
token := c.token
|
|
c.mu.Unlock()
|
|
|
|
req.Header.Set("X-F5-Auth-Token", token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
for k, v := range extraHeaders {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized && retryOn401 {
|
|
resp.Body.Close()
|
|
c.logger.Warn("F5 request returned 401, re-authenticating", "url", url)
|
|
if authErr := c.Authenticate(ctx); authErr != nil {
|
|
return nil, fmt.Errorf("F5 re-authentication failed: %w", authErr)
|
|
}
|
|
return c.doRequestInternal(ctx, method, url, bytes.NewReader(bodyBytes), extraHeaders, false)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// UploadFile uploads raw bytes via POST /mgmt/shared/file-transfer/uploads/{filename}.
|
|
// The Content-Range header is required even for single-chunk uploads (F5-specific).
|
|
func (c *realF5Client) UploadFile(ctx context.Context, filename string, data []byte) error {
|
|
url := fmt.Sprintf("%s/mgmt/shared/file-transfer/uploads/%s", c.baseURL, filename)
|
|
|
|
headers := map[string]string{
|
|
"Content-Type": "application/octet-stream",
|
|
"Content-Range": fmt.Sprintf("0-%d/%d", len(data)-1, len(data)),
|
|
}
|
|
|
|
resp, err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(data), headers)
|
|
if err != nil {
|
|
return fmt.Errorf("upload file %q failed: %w", filename, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("upload file %q failed with status %d: %s", filename, resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InstallCert installs an uploaded file as a crypto cert object.
|
|
func (c *realF5Client) InstallCert(ctx context.Context, name, localFile string) error {
|
|
url := c.baseURL + "/mgmt/tm/sys/crypto/cert"
|
|
body := map[string]string{
|
|
"command": "install",
|
|
"name": name,
|
|
"from-local-file": localFile,
|
|
}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
resp, err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("install cert %q failed: %w", name, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("install cert %q failed with status %d: %s", name, resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InstallKey installs an uploaded file as a crypto key object.
|
|
func (c *realF5Client) InstallKey(ctx context.Context, name, localFile string) error {
|
|
url := c.baseURL + "/mgmt/tm/sys/crypto/key"
|
|
body := map[string]string{
|
|
"command": "install",
|
|
"name": name,
|
|
"from-local-file": localFile,
|
|
}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
resp, err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("install key %q failed: %w", name, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("install key %q failed with status %d: %s", name, resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateTransaction starts an F5 transaction via POST /mgmt/tm/transaction.
|
|
func (c *realF5Client) CreateTransaction(ctx context.Context) (string, error) {
|
|
url := c.baseURL + "/mgmt/tm/transaction"
|
|
|
|
resp, err := c.doRequest(ctx, http.MethodPost, url, bytes.NewReader([]byte("{}")), nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create transaction failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("create transaction failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result struct {
|
|
TransID json.Number `json:"transId"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("failed to decode transaction response: %w", err)
|
|
}
|
|
|
|
transID := result.TransID.String()
|
|
if transID == "" {
|
|
return "", fmt.Errorf("F5 returned empty transaction ID")
|
|
}
|
|
|
|
return transID, nil
|
|
}
|
|
|
|
// CommitTransaction commits a transaction via PATCH /mgmt/tm/transaction/{id}.
|
|
func (c *realF5Client) CommitTransaction(ctx context.Context, transID string) error {
|
|
url := fmt.Sprintf("%s/mgmt/tm/transaction/%s", c.baseURL, transID)
|
|
body := map[string]string{"state": "VALIDATING"}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
resp, err := c.doRequest(ctx, http.MethodPatch, url, bytes.NewReader(bodyJSON), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("commit transaction %s failed: %w", transID, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("commit transaction %s failed with status %d: %s", transID, resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateSSLProfile updates an SSL client profile's cert/key/chain references.
|
|
// Uses tilde ~ as partition separator in the URL, forward slash / in JSON body values.
|
|
func (c *realF5Client) UpdateSSLProfile(ctx context.Context, partition, profile string, certName, keyName, chainName string, transID string) error {
|
|
url := fmt.Sprintf("%s/mgmt/tm/ltm/profile/client-ssl/~%s~%s", c.baseURL, partition, profile)
|
|
|
|
body := map[string]string{
|
|
"cert": partitionPath(partition, certName),
|
|
"key": partitionPath(partition, keyName),
|
|
}
|
|
if chainName != "" {
|
|
body["chain"] = partitionPath(partition, chainName)
|
|
}
|
|
bodyJSON, _ := json.Marshal(body)
|
|
|
|
headers := map[string]string{}
|
|
if transID != "" {
|
|
headers["X-F5-REST-Overriding-Collection"] = fmt.Sprintf("/mgmt/tm/transaction/%s", transID)
|
|
}
|
|
|
|
resp, err := c.doRequest(ctx, http.MethodPatch, url, bytes.NewReader(bodyJSON), headers)
|
|
if err != nil {
|
|
return fmt.Errorf("update SSL profile %q failed: %w", profile, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("update SSL profile %q failed with status %d: %s", profile, resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetSSLProfile retrieves an SSL client profile's configuration.
|
|
func (c *realF5Client) GetSSLProfile(ctx context.Context, partition, profile string) (*SSLProfileInfo, error) {
|
|
url := fmt.Sprintf("%s/mgmt/tm/ltm/profile/client-ssl/~%s~%s", c.baseURL, partition, profile)
|
|
|
|
resp, err := c.doRequest(ctx, http.MethodGet, url, nil, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get SSL profile %q failed: %w", profile, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("get SSL profile %q failed with status %d: %s", profile, resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var info SSLProfileInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
return nil, fmt.Errorf("failed to decode SSL profile response: %w", err)
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// DeleteCert removes a crypto cert object from the F5.
|
|
func (c *realF5Client) DeleteCert(ctx context.Context, partition, name string) error {
|
|
url := fmt.Sprintf("%s/mgmt/tm/sys/crypto/cert/~%s~%s", c.baseURL, partition, name)
|
|
|
|
resp, err := c.doRequest(ctx, http.MethodDelete, url, nil, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("delete cert %q failed: %w", name, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("delete cert %q failed with status %d: %s", name, resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteKey removes a crypto key object from the F5.
|
|
func (c *realF5Client) DeleteKey(ctx context.Context, partition, name string) error {
|
|
url := fmt.Sprintf("%s/mgmt/tm/sys/crypto/key/~%s~%s", c.baseURL, partition, name)
|
|
|
|
resp, err := c.doRequest(ctx, http.MethodDelete, url, nil, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("delete key %q failed: %w", name, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("delete key %q failed with status %d: %s", name, resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|