mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 23:28:58 +00:00
8b75e0311b
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 0729ee4 (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.
892 lines
31 KiB
Go
892 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 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 defaults to true because F5 management interfaces commonly use
|
|
// self-signed certificates. See TICKET-016 precedent for InsecureSkipVerify
|
|
// documentation. Operators running proper mgmt certs can set insecure=false.
|
|
}
|
|
|
|
// 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 the config.Insecure field
|
|
// (default true). Operators with proper management certs can set
|
|
// insecure=false. 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
|
|
}
|