Compare commits

..

3 Commits

Author SHA1 Message Date
shankar0123 9954fd1100 fix: remove unused installKeyErrOn field for golangci-lint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 22:29:34 -04:00
shankar0123 2a14a1da01 feat(M40): F5 BIG-IP target connector via iControl REST
Replace 190-line stub with full iControl REST implementation (~580 lines).
Token auth with 401 auto-retry, file upload + crypto object install,
transaction-based atomic SSL profile updates, cleanup on failure.
Injectable F5Client interface for cross-platform testing. 32 tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 22:26:58 -04:00
shankar0123 5a53b648b1 feat(M44): Google CAS issuer connector
Google Cloud Certificate Authority Service integration via REST API
with OAuth2 service account auth (JWT→access token). Synchronous
issuance model, CA pool selection, mutex-guarded token caching,
revocation with RFC 5280 reason mapping. No Google SDK dependency —
all stdlib. 19 tests with httptest mock OAuth2 + CAS API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:25:34 -04:00
16 changed files with 3313 additions and 107 deletions
+3 -2
View File
@@ -85,8 +85,9 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po
| Vault PKI | Beta | `VaultPKI` |
| DigiCert CertCentral | Beta | `DigiCert` |
| Sectigo SCM | Beta | `Sectigo` |
| Google CAS | Beta | `GoogleCAS` |
**Vault PKI, DigiCert, and Sectigo connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users.
**Vault PKI, DigiCert, Sectigo, and Google CAS connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users.
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
@@ -102,7 +103,7 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po
| Postfix | Implemented | `Postfix` |
| Dovecot | Implemented | `Dovecot` |
| Microsoft IIS | Implemented (local + WinRM) | `IIS` |
| F5 BIG-IP | Interface only | `F5` |
| F5 BIG-IP | Beta | `F5` |
### Notifiers
| Notifier | Status | Type |
+1 -1
View File
@@ -2643,7 +2643,7 @@ components:
# ─── Issuers ─────────────────────────────────────────────────────
IssuerType:
type: string
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo]
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS]
Issuer:
type: object
+5 -1
View File
@@ -585,7 +585,11 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess
return nil, fmt.Errorf("invalid F5 config: %w", err)
}
}
return f5.New(&cfg, a.logger), nil
conn, err := f5.New(&cfg, a.logger)
if err != nil {
return nil, fmt.Errorf("failed to create F5 connector: %w", err)
}
return conn, nil
case "IIS":
var cfg iis.Config
+18
View File
@@ -22,6 +22,7 @@ import (
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
googlecasissuer "github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
sectigoissuer "github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
@@ -172,6 +173,17 @@ func main() {
}, logger)
logger.Info("initialized Sectigo SCM issuer connector")
// Initialize Google CAS issuer connector (for GCP private CA).
// Uses the Google CAS REST API with OAuth2 service account auth.
googlecasConnector := googlecasissuer.New(&googlecasissuer.Config{
Project: cfg.GoogleCAS.Project,
Location: cfg.GoogleCAS.Location,
CAPool: cfg.GoogleCAS.CAPool,
Credentials: cfg.GoogleCAS.Credentials,
TTL: cfg.GoogleCAS.TTL,
}, logger)
logger.Info("initialized Google CAS issuer connector")
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
// "iss-local" matches the seed data issuer ID for the Local CA.
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
@@ -203,6 +215,12 @@ func main() {
logger.Info("Sectigo SCM issuer registered", "id", "iss-sectigo")
}
// Conditionally register Google CAS (only if project and credentials are set)
if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" {
issuerRegistry["iss-googlecas"] = service.NewIssuerConnectorAdapter(googlecasConnector)
logger.Info("Google CAS issuer registered", "id", "iss-googlecas")
}
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
// Initialize revocation repository
+4 -3
View File
@@ -92,7 +92,7 @@ flowchart TB
T7["Caddy\n(admin API / file)"]
T8["Envoy\n(file-based SDS)"]
T9["Postfix/Dovecot\n(file + service reload)"]
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
T2["F5 BIG-IP\n(proxy agent + iControl REST)"]
T3["IIS\n(WinRM + local)"]
end
@@ -418,7 +418,7 @@ The agent deploys certificates using target connectors. Each connector knows how
- **NGINX**: Writes cert/chain/key files to disk, validates config with `nginx -t`, reloads with `nginx -s reload` or `systemctl reload nginx`
- **Apache httpd**: Writes separate cert/chain/key files, validates with `apachectl configtest`, graceful reload
- **HAProxy**: Builds a combined PEM file (cert + chain + key), optionally validates config, reloads via systemctl or signal
- **F5 BIG-IP** (planned): A proxy agent in the same network zone calls the iControl REST API to upload certificate and update SSL profile bindings. The server assigns the work; the proxy agent executes it.
- **F5 BIG-IP**: A proxy agent in the same network zone calls the iControl REST API to upload certificate/key files, install crypto objects, and update the SSL client profile within an atomic transaction. The server assigns the work; the proxy agent executes it.
- **IIS** (implemented, dual-mode): (1) Agent-local (recommended) — a Windows agent on the IIS box runs PowerShell `Import-PfxCertificate` + `Set-WebBinding` directly with PFX conversion and SHA-1 thumbprint computation. (2) Proxy agent WinRM — for agentless IIS targets, a nearby Windows agent reaches the IIS box via WinRM.
The agent handles both the certificate (public) and the private key (read from local key store at `CERTCTL_KEY_DIR`). The control plane never sees the private key and never initiates outbound connections to agents or targets (pull-only model).
@@ -514,6 +514,7 @@ flowchart TB
II --> VP["Vault PKI"]
II --> DC["DigiCert CertCentral"]
II --> SG["Sectigo SCM"]
II --> GC["Google CAS"]
end
subgraph "Target Connectors"
@@ -527,7 +528,7 @@ flowchart TB
TI --> EV["Envoy"]
TI --> PO["Postfix/Dovecot"]
TI --> IIS["IIS"]
TI --> F5["F5 BIG-IP (interface only)"]
TI --> F5["F5 BIG-IP"]
end
subgraph "Notifier Connectors"
+37 -7
View File
@@ -379,12 +379,29 @@ The connector submits certificate enrollments to Sectigo's `/ssl/v1/enroll` API.
Location: `internal/connector/issuer/sectigo/sectigo.go`
### Built-in: Google CAS
Google Cloud Certificate Authority Service — managed private CA on GCP. Synchronous issuance via CAS REST API with OAuth2 service account auth.
| Setting | Required | Default | Description |
|---------|----------|---------|-------------|
| `CERTCTL_GOOGLE_CAS_PROJECT` | Yes | — | GCP project ID |
| `CERTCTL_GOOGLE_CAS_LOCATION` | Yes | — | GCP region (e.g., `us-central1`) |
| `CERTCTL_GOOGLE_CAS_CA_POOL` | Yes | — | CA pool name |
| `CERTCTL_GOOGLE_CAS_CREDENTIALS` | Yes | — | Path to service account JSON |
| `CERTCTL_GOOGLE_CAS_TTL` | No | `8760h` | Default certificate TTL |
**Authentication:** OAuth2 service account. The connector reads a service account JSON file, signs a JWT with the private key, and exchanges it for an access token at Google's token endpoint. Tokens are cached and refreshed automatically (5 min before expiry).
**Note:** CRL and OCSP are managed by Google CAS directly. certctl records revocations locally and notifies Google CAS via the revoke endpoint.
Location: `internal/connector/issuer/googlecas/googlecas.go`
### Coming in V2.2+
The following issuer connectors are planned for future releases:
- **Entrust** — Enterprise CA via Entrust API
- **Google CAS** — Google Cloud Certificate Authority Service
- **AWS ACM Private CA** — AWS-managed private CA
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
@@ -687,24 +704,37 @@ All commands are validated against shell injection via `validation.ValidateShell
Location: `internal/connector/target/postfix/postfix.go`
### F5 BIG-IP (Interface Only)
### F5 BIG-IP (Implemented)
The F5 BIG-IP target connector interface is defined with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it.
The F5 BIG-IP target connector deploys certificates to F5 load balancers via the iControl REST API. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated certctl 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+.
The planned flow is: authenticate via `POST /mgmt/shared/authn/login`, upload cert PEM via `POST /mgmt/tm/ltm/certificate`, update the SSL profile via `PATCH /mgmt/tm/ltm/profile/client-ssl/{profile}`, and validate deployment by checking profile status.
The deployment flow uses F5's transaction API for atomic updates: authenticate via token auth, upload cert/key/chain PEM files, install as crypto objects, update the SSL client profile within a transaction, and commit. If the transaction fails, F5 rolls back automatically and the connector cleans up uploaded crypto objects. Updating an SSL profile automatically takes effect on all bound virtual servers — no separate virtual server binding step is needed.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `host` | string | *(required)* | F5 BIG-IP management hostname or IP |
| `port` | int | `443` | iControl REST API port |
| `username` | string | *(required)* | Administrative username |
| `password` | string | *(required)* | Administrative password |
| `partition` | string | `Common` | F5 partition for crypto objects and profiles |
| `ssl_profile` | string | *(required)* | SSL client profile name to update |
| `insecure` | bool | `true` | Skip TLS verification for management interface (self-signed certs common) |
| `timeout` | int | `30` | HTTP timeout in seconds |
Configuration (defined, not yet functional):
```json
{
"host": "f5.internal.example.com",
"port": 443,
"username": "admin",
"password": "...",
"partition": "Common",
"ssl_profile": "/Common/clientssl_api"
"ssl_profile": "clientssl_api",
"insecure": true,
"timeout": 30
}
```
Note: F5 credentials are stored on the proxy agent, not on the control plane server. This limits the credential blast radius to the proxy agent's network zone.
F5 credentials are stored on the proxy agent, not on the control plane server. This limits the credential blast radius to the proxy agent's network zone. Config fields are validated against regex patterns to prevent injection.
Location: `internal/connector/target/f5/f5.go`
+140 -3
View File
@@ -6385,15 +6385,152 @@ These must be green before starting manual QA:
**PASS if** correct 3-header auth on all requests.
### Part 44: Google CAS Issuer Connector (M44)
**Prerequisites:** GCP project with Certificate Authority Service enabled, CA pool created, service account with `roles/privateca.certificateManager`, service account JSON key file.
#### Automated Tests
| Test | Description | Method | Pass? | Date | Notes |
|------|-------------|--------|-------|------|-------|
| 44.s1 | `IssuerTypeGoogleCAS` constant exists in domain | Auto | ☐ | | `grep 'GoogleCAS' internal/domain/connector.go` |
| 44.s2 | `GoogleCASConfig` struct exists in config | Auto | ☐ | | `grep 'GoogleCASConfig' internal/config/config.go` |
| 44.s3 | `iss-googlecas` in seed_demo.sql | Auto | ☐ | | `grep 'iss-googlecas' migrations/seed_demo.sql` |
| 44.s4 | GoogleCAS in OpenAPI IssuerType enum | Auto | ☐ | | `grep 'GoogleCAS' api/openapi.yaml` |
| 44.s5 | Google CAS connector tests pass | Auto | ☐ | | `go test ./internal/connector/issuer/googlecas/... -v` |
| 44.s6 | GoogleCAS in issuerTypes.ts | Auto | ☐ | | `grep 'GoogleCAS' web/src/config/issuerTypes.ts` |
| 44.s7 | Frontend build succeeds | Auto | ☐ | | `cd web && npm run build` |
| 44.s8 | Full Go build succeeds | Auto | ☐ | | `go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...` |
#### Manual Tests
**44.M1: Validate Google CAS Credentials**
1. Configure env vars: `CERTCTL_GOOGLE_CAS_PROJECT`, `CERTCTL_GOOGLE_CAS_LOCATION`, `CERTCTL_GOOGLE_CAS_CA_POOL`, `CERTCTL_GOOGLE_CAS_CREDENTIALS`
2. Start certctl server — verify log line: `Google CAS issuer registered`
3. Call `GET /api/v1/issuers` — verify `iss-googlecas` appears in the list
**PASS if** `iss-googlecas` registered and visible in API.
**44.M2: Issue Certificate via Google CAS**
1. Create a certificate with `issuer_id: iss-googlecas`
2. Trigger issuance — verify synchronous issuance (no async polling needed)
3. Verify PEM cert returned with correct CN and SANs
4. Verify certificate resource name stored in order_id field
**PASS if** certificate issued synchronously, PEM valid, resource name tracked.
**44.M3: Renewal via Google CAS**
1. Trigger renewal on a Google CAS-issued certificate
2. Verify new certificate issued (delegates to IssueCertificate)
3. Verify new serial number, updated validity dates
**PASS if** renewal produces new cert with new serial.
**44.M4: Revocation via Google CAS**
1. Revoke a Google CAS-issued certificate via `POST /api/v1/certificates/{id}/revoke`
2. Verify Google CAS revoke endpoint called (`POST {name}:revoke`)
3. Verify revocation reason mapped correctly (RFC 5280 → Google CAS enum)
4. Verify audit trail records revocation
**PASS if** revocation recorded in certctl and sent to Google CAS.
**44.M5: OAuth2 Token Caching**
1. Issue multiple certificates in quick succession
2. Verify token is cached (not re-fetched for every request)
3. Verify token refresh after expiry
**PASS if** token reuse observed, refresh works after expiry.
**44.M6: CA Certificate Retrieval**
1. Call EST cacerts endpoint with Google CAS as issuer
2. Verify CA certificate chain returned from Google CAS fetchCaCerts API
**PASS if** CA cert PEM returned successfully.
### Part 45: F5 BIG-IP Target Connector (M40)
**Prerequisites:** F5 BIG-IP device (v12.0+) with iControl REST enabled, admin credentials, SSL client profile configured, proxy agent in same network zone.
#### Automated Tests
| Test | Description | Method | Pass? | Date | Notes |
|------|-------------|--------|-------|------|-------|
| 45.s1 | `TargetTypeF5` constant exists in domain | Auto | ☐ | | `grep 'TargetTypeF5' internal/domain/connector.go` |
| 45.s2 | F5 connector tests pass | Auto | ☐ | | `go test ./internal/connector/target/f5/... -v` |
| 45.s3 | F5 config fields in TargetsPage.tsx | Auto | ☐ | | `grep 'ssl_profile' web/src/pages/TargetsPage.tsx` |
| 45.s4 | F5 in OpenAPI TargetType enum | Auto | ☐ | | `grep 'F5' api/openapi.yaml` |
| 45.s5 | Agent dispatch handles F5 error return | Auto | ☐ | | `grep 'f5.New' cmd/agent/main.go` |
| 45.s6 | F5 connector docs updated (not "Interface Only") | Auto | ☐ | | `grep 'Implemented' docs/connectors.md` |
| 45.s7 | Frontend build succeeds | Auto | ☐ | | `cd web && npm run build` |
| 45.s8 | Full Go build succeeds | Auto | ☐ | | `go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...` |
#### Manual Tests
**45.M1: Validate F5 Connectivity**
1. Configure proxy agent with F5 target (host, username, password, partition, ssl_profile)
2. Trigger ValidateConfig — verify authentication succeeds
3. Verify log line: `F5 configuration validated`
**PASS if** auth token obtained, no errors.
**45.M2: Deploy Certificate to F5**
1. Create certificate, assign to F5 target via proxy agent
2. Trigger deployment — verify full iControl REST flow (upload → install → transaction → profile update → commit)
3. Verify SSL profile updated via F5 management GUI or `GET /mgmt/tm/ltm/profile/client-ssl/~Common~{profile}`
4. Verify virtual servers bound to the profile serve the new cert
**PASS if** certificate deployed, profile updated, virtual servers serving new cert.
**45.M3: Deploy Without Chain**
1. Issue a cert without chain (self-signed or single-issuer)
2. Deploy to F5 — verify chain upload/install steps are skipped
3. Verify profile updated with cert and key only (no chain field)
**PASS if** deployment succeeds without chain, profile has cert/key but no chain.
**45.M4: Transaction Rollback on Failure**
1. Configure an invalid SSL profile name
2. Trigger deployment — verify upload/install succeeds but profile update fails
3. Verify transaction rolled back (F5 auto-rollback)
4. Verify cleanup: uploaded crypto objects deleted from F5
**PASS if** error reported, crypto objects cleaned up.
**45.M5: Validate Deployment**
1. After successful deployment, call ValidateDeployment
2. Verify SSL profile queried and cert name returned in metadata
3. Verify `current_cert` metadata matches the deployed cert object name
**PASS if** validation returns Valid=true with correct cert reference.
**45.M6: Token Refresh on 401**
1. Deploy with valid credentials
2. Wait for token to expire (or manually invalidate)
3. Trigger another deployment — verify automatic re-authentication and retry
**PASS if** deployment succeeds after token refresh.
### Summary
| Category | Count |
|----------|-------|
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
| ☐ Auto (not yet run) | 20 |
| ☐ Auto (not yet run) | 36 |
| — Skipped (preconditions not met in demo) | 5 |
| ☐ Manual (requires hands-on verification) | 247 |
| **Total** | **416** |
| ☐ Manual (requires hands-on verification) | 259 |
| **Total** | **444** |
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
+36
View File
@@ -28,6 +28,7 @@ type Config struct {
Vault VaultConfig
DigiCert DigiCertConfig
Sectigo SectigoConfig
GoogleCAS GoogleCASConfig
Digest DigestConfig
}
@@ -232,6 +233,34 @@ type SectigoConfig struct {
BaseURL string
}
// GoogleCASConfig contains Google Cloud Certificate Authority Service configuration.
type GoogleCASConfig struct {
// Project is the GCP project ID.
// Required for Google CAS integration.
// Setting: CERTCTL_GOOGLE_CAS_PROJECT environment variable.
Project string
// Location is the GCP region (e.g., "us-central1").
// Required for Google CAS integration.
// Setting: CERTCTL_GOOGLE_CAS_LOCATION environment variable.
Location string
// CAPool is the Certificate Authority pool name.
// Required for Google CAS integration.
// Setting: CERTCTL_GOOGLE_CAS_CA_POOL environment variable.
CAPool string
// Credentials is the path to the service account JSON credentials file.
// Required for Google CAS integration.
// Setting: CERTCTL_GOOGLE_CAS_CREDENTIALS environment variable.
Credentials string
// TTL is the default certificate time-to-live.
// Default: "8760h" (1 year).
// Setting: CERTCTL_GOOGLE_CAS_TTL environment variable.
TTL string
}
// DigestConfig controls the scheduled certificate digest email feature.
type DigestConfig struct {
// Enabled controls whether periodic digest emails are generated and sent.
@@ -547,6 +576,13 @@ func Load() (*Config, error) {
Term: getEnvInt("CERTCTL_SECTIGO_TERM", 365),
BaseURL: getEnv("CERTCTL_SECTIGO_BASE_URL", "https://cert-manager.com/api"),
},
GoogleCAS: GoogleCASConfig{
Project: getEnv("CERTCTL_GOOGLE_CAS_PROJECT", ""),
Location: getEnv("CERTCTL_GOOGLE_CAS_LOCATION", ""),
CAPool: getEnv("CERTCTL_GOOGLE_CAS_CA_POOL", ""),
Credentials: getEnv("CERTCTL_GOOGLE_CAS_CREDENTIALS", ""),
TTL: getEnv("CERTCTL_GOOGLE_CAS_TTL", "8760h"),
},
ACME: ACMEConfig{
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
@@ -0,0 +1,619 @@
// Package googlecas implements the issuer.Connector interface for
// Google Cloud Certificate Authority Service (CAS).
//
// Google CAS is a managed private CA service on GCP. This connector
// uses the CAS REST API (privateca.googleapis.com/v1) with OAuth2
// service account authentication. Certificates are issued synchronously.
//
// Authentication: OAuth2 service account via JWT → access token exchange.
// No Google SDK dependency — uses stdlib crypto/rsa + net/http.
//
// API endpoints used:
//
// POST /v1/{parent}/certificates - Issue certificate
// POST /v1/{name}:revoke - Revoke certificate
// POST /v1/{caPool}:fetchCaCerts - Get CA certificate chain
package googlecas
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the Google CAS issuer connector configuration.
type Config struct {
// Project is the GCP project ID.
// Required. Set via CERTCTL_GOOGLE_CAS_PROJECT environment variable.
Project string `json:"project"`
// Location is the GCP region (e.g., "us-central1").
// Required. Set via CERTCTL_GOOGLE_CAS_LOCATION environment variable.
Location string `json:"location"`
// CAPool is the Certificate Authority pool name.
// Required. Set via CERTCTL_GOOGLE_CAS_CA_POOL environment variable.
CAPool string `json:"ca_pool"`
// Credentials is the path to the service account JSON credentials file.
// Required. Set via CERTCTL_GOOGLE_CAS_CREDENTIALS environment variable.
Credentials string `json:"credentials"`
// TTL is the requested certificate TTL (e.g., "8760h" for 1 year).
// Default: "8760h". Set via CERTCTL_GOOGLE_CAS_TTL environment variable.
TTL string `json:"ttl"`
// BaseURL overrides the Google CAS API base URL (for testing).
// Default: "https://privateca.googleapis.com/v1".
BaseURL string `json:"base_url,omitempty"`
// TokenURL overrides the OAuth2 token endpoint (for testing).
// Default: "https://oauth2.googleapis.com/token".
TokenURL string `json:"token_url,omitempty"`
}
// serviceAccountKey represents the relevant fields from a Google service account JSON file.
type serviceAccountKey struct {
Type string `json:"type"`
ProjectID string `json:"project_id"`
PrivateKey string `json:"private_key"`
ClientEmail string `json:"client_email"`
TokenURI string `json:"token_uri"`
}
// cachedToken holds an OAuth2 access token and its expiry.
type cachedToken struct {
token string
expiresAt time.Time
}
// Connector implements the issuer.Connector interface for Google CAS.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
// OAuth2 token caching
mu sync.Mutex
tokenCache *cachedToken
saKey *serviceAccountKey
rsaKey *rsa.PrivateKey
}
// New creates a new Google CAS connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
if config != nil {
if config.TTL == "" {
config.TTL = "8760h"
}
if config.BaseURL == "" {
config.BaseURL = "https://privateca.googleapis.com/v1"
}
if config.TokenURL == "" {
config.TokenURL = "https://oauth2.googleapis.com/token"
}
}
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// parentPath returns the CAS resource parent path.
func (c *Connector) parentPath() string {
return fmt.Sprintf("projects/%s/locations/%s/caPools/%s",
c.config.Project, c.config.Location, c.config.CAPool)
}
// certificateCreateResponse represents the Google CAS create certificate response.
type certificateCreateResponse struct {
Name string `json:"name"`
PEMCertificate string `json:"pemCertificate"`
PEMCertificateChain []string `json:"pemCertificateChain"`
}
// fetchCACertsResponse represents the Google CAS fetchCaCerts response.
type fetchCACertsResponse struct {
CACerts []caCertChain `json:"caCerts"`
}
type caCertChain struct {
Certificates []string `json:"certificates"`
}
// googleAPIError represents a Google API error response.
type googleAPIError struct {
Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
} `json:"error"`
}
// ValidateConfig checks that the Google CAS configuration is valid.
// Verifies required fields and that the credentials file is parseable.
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 Google CAS config: %w", err)
}
if cfg.Project == "" {
return fmt.Errorf("Google CAS project is required")
}
if cfg.Location == "" {
return fmt.Errorf("Google CAS location is required")
}
if cfg.CAPool == "" {
return fmt.Errorf("Google CAS CA pool is required")
}
if cfg.Credentials == "" {
return fmt.Errorf("Google CAS credentials path is required")
}
// Verify credentials file exists and is valid
saKey, _, err := loadServiceAccountKey(cfg.Credentials)
if err != nil {
return fmt.Errorf("Google CAS credentials invalid: %w", err)
}
if saKey.ClientEmail == "" {
return fmt.Errorf("Google CAS credentials missing client_email")
}
if saKey.PrivateKey == "" {
return fmt.Errorf("Google CAS credentials missing private_key")
}
if cfg.TTL == "" {
cfg.TTL = "8760h"
}
if cfg.BaseURL == "" {
cfg.BaseURL = "https://privateca.googleapis.com/v1"
}
if cfg.TokenURL == "" {
cfg.TokenURL = "https://oauth2.googleapis.com/token"
}
c.config = &cfg
c.logger.Info("Google CAS configuration validated",
"project", cfg.Project,
"location", cfg.Location,
"ca_pool", cfg.CAPool)
return nil
}
// loadServiceAccountKey reads and parses a service account JSON file.
func loadServiceAccountKey(path string) (*serviceAccountKey, *rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("cannot read credentials file: %w", err)
}
var saKey serviceAccountKey
if err := json.Unmarshal(data, &saKey); err != nil {
return nil, nil, fmt.Errorf("cannot parse credentials JSON: %w", err)
}
if saKey.PrivateKey == "" {
return &saKey, nil, nil
}
// Parse the RSA private key
block, _ := pem.Decode([]byte(saKey.PrivateKey))
if block == nil {
return nil, nil, fmt.Errorf("cannot decode private key PEM")
}
// Try PKCS#8 first, then PKCS#1
var rsaKey *rsa.PrivateKey
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
var ok bool
rsaKey, ok = key.(*rsa.PrivateKey)
if !ok {
return nil, nil, fmt.Errorf("private key is not RSA")
}
} else if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
rsaKey = key
} else {
return nil, nil, fmt.Errorf("cannot parse private key: not PKCS#8 or PKCS#1")
}
return &saKey, rsaKey, nil
}
// getAccessToken returns a valid OAuth2 access token, refreshing if needed.
func (c *Connector) getAccessToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
// Return cached token if still valid (5 min buffer)
if c.tokenCache != nil && time.Now().Add(5*time.Minute).Before(c.tokenCache.expiresAt) {
return c.tokenCache.token, nil
}
// Load credentials if not cached
if c.saKey == nil || c.rsaKey == nil {
saKey, rsaKey, err := loadServiceAccountKey(c.config.Credentials)
if err != nil {
return "", fmt.Errorf("failed to load credentials: %w", err)
}
c.saKey = saKey
c.rsaKey = rsaKey
}
// Build JWT
now := time.Now()
header := base64URLEncode([]byte(`{"alg":"RS256","typ":"JWT"}`))
claims, err := json.Marshal(map[string]interface{}{
"iss": c.saKey.ClientEmail,
"scope": "https://www.googleapis.com/auth/cloud-platform",
"aud": c.config.TokenURL,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
})
if err != nil {
return "", fmt.Errorf("failed to marshal JWT claims: %w", err)
}
payload := base64URLEncode(claims)
// Sign
signingInput := header + "." + payload
hash := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, c.rsaKey, crypto.SHA256, hash[:])
if err != nil {
return "", fmt.Errorf("failed to sign JWT: %w", err)
}
jwt := signingInput + "." + base64URLEncode(sig)
// Exchange JWT for access token
form := url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
"assertion": {jwt},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.TokenURL,
strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("token exchange failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read token response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token exchange returned status %d: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", fmt.Errorf("failed to parse token response: %w", err)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("empty access token in response")
}
// Cache token
c.tokenCache = &cachedToken{
token: tokenResp.AccessToken,
expiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
}
return tokenResp.AccessToken, nil
}
// doAuthenticatedRequest performs an HTTP request with OAuth2 bearer token.
func (c *Connector) doAuthenticatedRequest(ctx context.Context, method, urlStr string, body interface{}) ([]byte, int, error) {
token, err := c.getAccessToken(ctx)
if err != nil {
return nil, 0, fmt.Errorf("failed to get access token: %w", err)
}
var bodyReader io.Reader
if body != nil {
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, 0, fmt.Errorf("failed to marshal request body: %w", err)
}
bodyReader = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
if err != nil {
return nil, 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("failed to read response: %w", err)
}
return respBody, resp.StatusCode, nil
}
// extractAPIError extracts an error message from a Google API error response.
func extractAPIError(body []byte) string {
var apiErr googleAPIError
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Error.Message != "" {
return fmt.Sprintf("%s (%s)", apiErr.Error.Message, apiErr.Error.Status)
}
return string(body)
}
// IssueCertificate issues a new certificate via Google CAS.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing Google CAS issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Convert TTL to seconds string
ttlDuration, err := time.ParseDuration(c.config.TTL)
if err != nil {
return nil, fmt.Errorf("invalid TTL %q: %w", c.config.TTL, err)
}
lifetimeSeconds := fmt.Sprintf("%ds", int(ttlDuration.Seconds()))
// Generate unique certificate ID
certID := fmt.Sprintf("certctl-%d-%s", time.Now().Unix(), randomHex(4))
// Build request
createURL := fmt.Sprintf("%s/%s/certificates?certificateId=%s",
c.config.BaseURL, c.parentPath(), certID)
createBody := map[string]interface{}{
"lifetime": lifetimeSeconds,
"pemCsr": request.CSRPEM,
}
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, createURL, createBody)
if err != nil {
return nil, fmt.Errorf("Google CAS create certificate failed: %w", err)
}
if statusCode != http.StatusOK {
return nil, fmt.Errorf("Google CAS create certificate returned status %d: %s",
statusCode, extractAPIError(respBody))
}
// Parse response
var certResp certificateCreateResponse
if err := json.Unmarshal(respBody, &certResp); err != nil {
return nil, fmt.Errorf("failed to parse Google CAS response: %w", err)
}
if certResp.PEMCertificate == "" {
return nil, fmt.Errorf("no certificate in Google CAS response")
}
// Parse leaf cert to extract metadata
block, _ := pem.Decode([]byte(certResp.PEMCertificate))
if block == nil {
return nil, fmt.Errorf("failed to decode certificate PEM from Google CAS")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Build chain PEM
chainPEM := strings.Join(certResp.PEMCertificateChain, "\n")
serial := formatSerial(cert.SerialNumber)
// Store full resource name as OrderID for revocation lookup
orderID := certResp.Name
c.logger.Info("Google CAS certificate issued",
"common_name", request.CommonName,
"serial", serial,
"name", certResp.Name,
"not_after", cert.NotAfter)
return &issuer.IssuanceResult{
CertPEM: certResp.PEMCertificate,
ChainPEM: chainPEM,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}, nil
}
// RenewCertificate renews a certificate by creating a new one.
// For Google CAS, renewal is functionally identical to issuance.
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing Google CAS 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 Google CAS.
// The serial field should contain the full certificate resource name (set as OrderID at issuance).
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing Google CAS revocation request", "serial", request.Serial)
// Determine the certificate resource name.
// If serial starts with "projects/", it's a full resource name (from OrderID).
// Otherwise, construct a best-effort path.
var certName string
if strings.HasPrefix(request.Serial, "projects/") {
certName = request.Serial
} else {
certName = fmt.Sprintf("%s/certificates/%s", c.parentPath(), request.Serial)
}
reason := mapRevocationReason(request.Reason)
revokeURL := fmt.Sprintf("%s/%s:revoke", c.config.BaseURL, certName)
revokeBody := map[string]interface{}{
"reason": reason,
}
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, revokeURL, revokeBody)
if err != nil {
return fmt.Errorf("Google CAS revoke failed: %w", err)
}
if statusCode != http.StatusOK {
return fmt.Errorf("Google CAS revoke returned status %d: %s",
statusCode, extractAPIError(respBody))
}
c.logger.Info("Google CAS certificate revoked", "name", certName, "reason", reason)
return nil
}
// GetOrderStatus returns the status of a Google CAS order.
// Google CAS signs synchronously, so orders are always "completed" immediately.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
UpdatedAt: time.Now(),
}, nil
}
// GenerateCRL is not supported because Google CAS manages CRL directly.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("Google CAS manages CRL directly; not supported via certctl")
}
// SignOCSPResponse is not supported because Google CAS manages OCSP directly.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("Google CAS manages OCSP directly; not supported via certctl")
}
// GetCACertPEM retrieves the CA certificate chain from Google CAS.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
fetchURL := fmt.Sprintf("%s/%s:fetchCaCerts", c.config.BaseURL, c.parentPath())
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, fetchURL, map[string]interface{}{})
if err != nil {
return "", fmt.Errorf("Google CAS fetchCaCerts failed: %w", err)
}
if statusCode != http.StatusOK {
return "", fmt.Errorf("Google CAS fetchCaCerts returned status %d: %s",
statusCode, extractAPIError(respBody))
}
var resp fetchCACertsResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return "", fmt.Errorf("failed to parse fetchCaCerts response: %w", err)
}
if len(resp.CACerts) == 0 || len(resp.CACerts[0].Certificates) == 0 {
return "", fmt.Errorf("no CA certificates in response")
}
// Join all certificates from the first CA cert chain
return strings.Join(resp.CACerts[0].Certificates, "\n"), nil
}
// GetRenewalInfo returns nil, nil as Google CAS does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// mapRevocationReason maps certctl RFC 5280 reason strings to Google CAS enum values.
func mapRevocationReason(reason *string) string {
if reason == nil {
return "REVOCATION_REASON_UNSPECIFIED"
}
switch strings.ToLower(*reason) {
case "keycompromise":
return "KEY_COMPROMISE"
case "cacompromise":
return "CERTIFICATE_AUTHORITY_COMPROMISE"
case "affiliationchanged":
return "AFFILIATION_CHANGED"
case "superseded":
return "SUPERSEDED"
case "cessationofoperation":
return "CESSATION_OF_OPERATION"
case "certificatehold":
return "CERTIFICATE_HOLD"
case "privilegewithdrawn":
return "PRIVILEGE_WITHDRAWN"
default:
return "REVOCATION_REASON_UNSPECIFIED"
}
}
// formatSerial converts a *big.Int serial number to a hex string.
func formatSerial(serial *big.Int) string {
return serial.Text(16)
}
// randomHex generates n random bytes and returns them as a hex string.
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return fmt.Sprintf("%x", b)
}
// base64URLEncode encodes data using base64url without padding.
func base64URLEncode(data []byte) string {
return base64.RawURLEncoding.EncodeToString(data)
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,826 @@
package googlecas_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
)
func TestGoogleCASConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("ValidateConfig_Success", func(t *testing.T) {
credPath := createTestCredentialsFile(t)
config := googlecas.Config{
Project: "my-project",
Location: "us-central1",
CAPool: "my-pool",
Credentials: credPath,
TTL: "8760h",
}
connector := googlecas.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_MissingProject", func(t *testing.T) {
config := googlecas.Config{
Location: "us-central1",
CAPool: "my-pool",
Credentials: "/tmp/creds.json",
}
connector := googlecas.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing project")
}
if !strings.Contains(err.Error(), "project is required") {
t.Errorf("Expected project required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingLocation", func(t *testing.T) {
config := googlecas.Config{
Project: "my-project",
CAPool: "my-pool",
Credentials: "/tmp/creds.json",
}
connector := googlecas.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing location")
}
if !strings.Contains(err.Error(), "location is required") {
t.Errorf("Expected location required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingCAPool", func(t *testing.T) {
config := googlecas.Config{
Project: "my-project",
Location: "us-central1",
Credentials: "/tmp/creds.json",
}
connector := googlecas.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing CA pool")
}
if !strings.Contains(err.Error(), "CA pool is required") {
t.Errorf("Expected CA pool required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingCredentials", func(t *testing.T) {
config := googlecas.Config{
Project: "my-project",
Location: "us-central1",
CAPool: "my-pool",
}
connector := googlecas.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing credentials")
}
if !strings.Contains(err.Error(), "credentials path is required") {
t.Errorf("Expected credentials required error, got: %v", err)
}
})
t.Run("ValidateConfig_InvalidCredentialsFile", func(t *testing.T) {
config := googlecas.Config{
Project: "my-project",
Location: "us-central1",
CAPool: "my-pool",
Credentials: "/nonexistent/path/credentials.json",
}
connector := googlecas.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for invalid credentials file")
}
if !strings.Contains(err.Error(), "credentials invalid") {
t.Errorf("Expected credentials invalid error, got: %v", err)
}
})
t.Run("ValidateConfig_MalformedCredentialsJSON", func(t *testing.T) {
tmpDir := t.TempDir()
badFile := filepath.Join(tmpDir, "bad-creds.json")
if err := os.WriteFile(badFile, []byte("not json"), 0600); err != nil {
t.Fatal(err)
}
config := googlecas.Config{
Project: "my-project",
Location: "us-central1",
CAPool: "my-pool",
Credentials: badFile,
}
connector := googlecas.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for malformed credentials JSON")
}
if !strings.Contains(err.Error(), "credentials invalid") {
t.Errorf("Expected credentials invalid error, got: %v", err)
}
})
t.Run("IssueCertificate_Success", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
credPath := createTestCredentialsFile(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"test-token-12345","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost &&
!strings.Contains(r.URL.Path, ":revoke") && !strings.Contains(r.URL.Path, ":fetchCaCerts"):
// Verify auth header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-token-12345" {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":{"code":403,"message":"Permission denied","status":"PERMISSION_DENIED"}}`))
return
}
// Verify certificateId query param
certID := r.URL.Query().Get("certificateId")
if certID == "" {
t.Error("Missing certificateId query parameter")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
chainCert, _ := generateTestCert(t)
resp := fmt.Sprintf(`{
"name": "projects/test-project/locations/us-central1/caPools/test-pool/certificates/%s",
"pemCertificate": %q,
"pemCertificateChain": [%q]
}`, certID, testCertPEM, chainCert)
w.Write([]byte(resp))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
_, csrPEM := generateTestCSR(t, "app.example.com")
req := issuer.IssuanceRequest{
CommonName: "app.example.com",
SANs: []string{"app.example.com", "www.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM is empty")
}
if result.Serial == "" {
t.Error("Serial is empty")
}
if result.OrderID == "" {
t.Error("OrderID is empty")
}
if !strings.HasPrefix(result.OrderID, "projects/") {
t.Errorf("Expected OrderID to be full resource name, got '%s'", result.OrderID)
}
if result.ChainPEM == "" {
t.Error("ChainPEM is empty")
}
if result.NotBefore.IsZero() {
t.Error("NotBefore is zero")
}
if result.NotAfter.IsZero() {
t.Error("NotAfter is zero")
}
t.Logf("Google CAS issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
credPath := createTestCredentialsFile(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, "/certificates"):
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":{"code":400,"message":"Invalid CSR","status":"INVALID_ARGUMENT"}}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
_, csrPEM := generateTestCSR(t, "test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for server error response")
}
if !strings.Contains(err.Error(), "Invalid CSR") {
t.Logf("Got error: %v", err)
}
})
t.Run("IssueCertificate_InvalidResponse", func(t *testing.T) {
credPath := createTestCredentialsFile(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, "/certificates"):
w.WriteHeader(http.StatusOK)
w.Write([]byte(`not-json`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
_, csrPEM := generateTestCSR(t, "test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for invalid response")
}
if !strings.Contains(err.Error(), "parse") {
t.Logf("Got error: %v", err)
}
})
t.Run("GetOrderStatus_AlwaysCompleted", func(t *testing.T) {
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
TTL: "8760h",
}
connector := googlecas.New(config, logger)
status, err := connector.GetOrderStatus(ctx, "projects/p/locations/l/caPools/cp/certificates/cert-123")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
if status.OrderID != "projects/p/locations/l/caPools/cp/certificates/cert-123" {
t.Errorf("Expected OrderID preserved, got '%s'", status.OrderID)
}
})
t.Run("RenewCertificate_NewCert", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
credPath := createTestCredentialsFile(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost &&
!strings.Contains(r.URL.Path, ":revoke"):
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp := fmt.Sprintf(`{
"name": "projects/test-project/locations/us-central1/caPools/test-pool/certificates/certctl-renew",
"pemCertificate": %q,
"pemCertificateChain": []
}`, testCertPEM)
w.Write([]byte(resp))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
_, csrPEM := generateTestCSR(t, "renew.example.com")
renewReq := issuer.RenewalRequest{
CommonName: "renew.example.com",
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.Serial == "" {
t.Error("Serial is empty")
}
})
t.Run("RevokeCertificate_Success", func(t *testing.T) {
credPath := createTestCredentialsFile(t)
var receivedReason string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, ":revoke"):
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
receivedReason = body["reason"].(string)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name":"projects/p/locations/l/caPools/cp/certificates/cert-123"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "projects/test-project/locations/us-central1/caPools/test-pool/certificates/cert-123",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
if receivedReason != "KEY_COMPROMISE" {
t.Errorf("Expected reason 'KEY_COMPROMISE', got '%s'", receivedReason)
}
})
t.Run("RevokeCertificate_Error", func(t *testing.T) {
credPath := createTestCredentialsFile(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, ":revoke"):
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":{"code":404,"message":"Certificate not found","status":"NOT_FOUND"}}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
revokeReq := issuer.RevocationRequest{
Serial: "projects/test-project/locations/us-central1/caPools/test-pool/certificates/nonexistent",
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err == nil {
t.Fatal("Expected error for revoke of nonexistent certificate")
}
if !strings.Contains(err.Error(), "Certificate not found") {
t.Logf("Got error: %v", err)
}
})
t.Run("RevocationReasonMapping", func(t *testing.T) {
credPath := createTestCredentialsFile(t)
tests := []struct {
name string
reason string
expected string
}{
{"keyCompromise", "keyCompromise", "KEY_COMPROMISE"},
{"caCompromise", "caCompromise", "CERTIFICATE_AUTHORITY_COMPROMISE"},
{"affiliationChanged", "affiliationChanged", "AFFILIATION_CHANGED"},
{"superseded", "superseded", "SUPERSEDED"},
{"cessationOfOperation", "cessationOfOperation", "CESSATION_OF_OPERATION"},
{"certificateHold", "certificateHold", "CERTIFICATE_HOLD"},
{"privilegeWithdrawn", "privilegeWithdrawn", "PRIVILEGE_WITHDRAWN"},
{"unspecified", "unspecified", "REVOCATION_REASON_UNSPECIFIED"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var receivedReason string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, ":revoke"):
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
receivedReason = body["reason"].(string)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
reason := tc.reason
err := connector.RevokeCertificate(ctx, issuer.RevocationRequest{
Serial: "projects/p/locations/l/caPools/cp/certificates/cert-1",
Reason: &reason,
})
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
if receivedReason != tc.expected {
t.Errorf("Expected reason '%s', got '%s'", tc.expected, receivedReason)
}
})
}
})
t.Run("GetCACertPEM_Success", func(t *testing.T) {
credPath := createTestCredentialsFile(t)
caCertPEM, _ := generateTestCert(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, ":fetchCaCerts"):
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp := fmt.Sprintf(`{"caCerts":[{"certificates":[%q]}]}`, caCertPEM)
w.Write([]byte(resp))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
caPEM, err := connector.GetCACertPEM(ctx)
if err != nil {
t.Fatalf("GetCACertPEM failed: %v", err)
}
if !strings.Contains(caPEM, "BEGIN CERTIFICATE") {
t.Errorf("Expected CA PEM to contain certificate, got: %s", caPEM[:50])
}
})
t.Run("GetCACertPEM_Error", func(t *testing.T) {
credPath := createTestCredentialsFile(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, ":fetchCaCerts"):
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":{"code":403,"message":"Permission denied","status":"PERMISSION_DENIED"}}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
_, err := connector.GetCACertPEM(ctx)
if err == nil {
t.Fatal("Expected error for permission denied")
}
})
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
}
connector := googlecas.New(config, logger)
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
if err != nil {
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
}
if result != nil {
t.Fatal("GetRenewalInfo should return nil for Google CAS")
}
})
t.Run("AuthHeader_BearerToken", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
credPath := createTestCredentialsFile(t)
var authHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/token":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"access_token":"verified-token-abc","expires_in":3600,"token_type":"Bearer"}`))
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost:
authHeader = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp := fmt.Sprintf(`{
"name": "projects/p/locations/l/caPools/cp/certificates/c1",
"pemCertificate": %q,
"pemCertificateChain": []
}`, testCertPEM)
w.Write([]byte(resp))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &googlecas.Config{
Project: "test-project",
Location: "us-central1",
CAPool: "test-pool",
Credentials: credPath,
TTL: "8760h",
BaseURL: srv.URL,
TokenURL: srv.URL + "/token",
}
connector := googlecas.New(config, logger)
_, csrPEM := generateTestCSR(t, "auth-test.example.com")
_, err := connector.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: "auth-test.example.com",
CSRPEM: csrPEM,
})
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if authHeader != "Bearer verified-token-abc" {
t.Errorf("Expected 'Bearer verified-token-abc', got '%s'", authHeader)
}
})
}
// createTestCredentialsFile generates a temporary service account JSON file with a test RSA key.
func createTestCredentialsFile(t *testing.T) string {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
creds := map[string]interface{}{
"type": "service_account",
"project_id": "test-project",
"private_key_id": "key-123",
"private_key": string(keyPEM),
"client_email": "certctl@test-project.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token",
}
data, err := json.Marshal(creds)
if err != nil {
t.Fatalf("Failed to marshal credentials: %v", err)
}
tmpDir := t.TempDir()
credPath := filepath.Join(tmpDir, "credentials.json")
if err := os.WriteFile(credPath, data, 0600); err != nil {
t.Fatalf("Failed to write credentials file: %v", err)
}
return credPath
}
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "Test Certificate",
},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"test.example.com"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
return certPEM, keyPEM
}
// generateTestCSR creates a test CSR for the given common name.
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
csrTemplate := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: []string{commonName},
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
if err != nil {
t.Fatalf("Failed to create CSR: %v", err)
}
csrPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
}))
csr, err := x509.ParseCertificateRequest(csrBytes)
if err != nil {
t.Fatalf("Failed to parse CSR: %v", err)
}
return csr, csrPEM
}
+788 -86
View File
@@ -1,108 +1,269 @@
package f5
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/shankar0123/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 hostname or IP
Port int `json:"port"` // F5 iControl REST API port (default 443)
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 (e.g., "Common")
SSLProfile string `json:"ssl_profile"` // SSL profile name to update
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 and manage SSL profiles.
// 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.
//
// TODO: Implement actual F5 iControl REST API communication.
// The documented API endpoints and flow are:
// - Authentication: POST /mgmt/shared/authn/login
// - Upload certificate: POST /mgmt/tm/ltm/certificate
// - Update SSL profile: PATCH /mgmt/tm/ltm/profile/client-ssl/{profile_name}
// - Check SSL profile: GET /mgmt/tm/ltm/profile/client-ssl/{profile_name}
// Minimum supported BIG-IP version: 12.0+.
type Connector struct {
config *Config
logger *slog.Logger
client *http.Client
client F5Client
}
// New creates a new F5 target connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
// 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: &http.Client{
Timeout: 30 * time.Second,
// TODO: Configure proper TLS verification or skip for self-signed F5 certs
},
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 attempts to authenticate to the F5 iControl REST API.
//
// TODO: Implement actual F5 authentication validation.
// 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)
}
if cfg.Host == "" || cfg.Username == "" || cfg.Password == "" {
return fmt.Errorf("F5 host, username, and password are required")
// 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")
}
if cfg.Port == 0 {
cfg.Port = 443 // Default HTTPS port
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)")
}
if cfg.Partition == "" {
cfg.Partition = "Common"
// 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)
"partition", cfg.Partition,
"ssl_profile", cfg.SSLProfile)
// TODO: Implement F5 authentication check
// In production:
// 1. POST to https://{host}:{port}/mgmt/shared/authn/login
// 2. Send credentials in request body
// 3. Verify response contains valid authentication token
// 4. Optionally test connectivity to SSL profile endpoint
c.logger.Warn("F5 validation not yet fully implemented",
"host", cfg.Host)
// 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 F5 deployment process:
// 1. Authenticate to iControl REST API using credentials
// 2. Upload certificate PEM to /mgmt/tm/ltm/certificate
// 3. Upload chain PEM as separate certificate if needed
// 4. Update the target SSL profile to reference the new certificate
// 5. Verify the profile was updated successfully
// 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)
//
// TODO: Implement actual F5 iControl REST API calls.
// API endpoints used:
// - POST /mgmt/shared/authn/login (authentication)
// - POST /mgmt/tm/ltm/certificate (upload cert)
// - PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile} (update profile)
// 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,
@@ -111,47 +272,233 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
startTime := time.Now()
// TODO: Implement F5 certificate deployment
// In production:
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
// 2. Create certificate object:
// POST /mgmt/tm/ltm/certificate
// Body: {"name": "certctl-cert-{timestamp}", "certificateText": "{CertPEM}"}
// 3. If chain is provided, upload as separate certificate:
// POST /mgmt/tm/ltm/certificate
// Body: {"name": "certctl-chain-{timestamp}", "certificateText": "{ChainPEM}"}
// 4. Update SSL profile:
// PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
// Body: {"certificate": "/Common/certctl-cert-{timestamp}"}
// 5. Verify deployment by checking profile status
// 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.Warn("F5 deployment not yet implemented",
c.logger.Info("certificate deployed to F5 BIG-IP successfully",
"duration", deploymentDuration.String(),
"host", c.config.Host,
"ssl_profile", c.config.SSLProfile)
"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-%d", time.Now().Unix()),
Message: "Certificate deployment to F5 initiated (stub)",
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,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
"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 checks the SSL profile configuration to ensure it references the correct certificate.
//
// TODO: Implement actual F5 validation via iControl REST API.
// API endpoint used:
// - GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
// 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,
@@ -160,30 +507,385 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
startTime := time.Now()
// TODO: Implement F5 deployment validation
// In production:
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
// 2. Query SSL profile:
// GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
// 3. Verify the response includes the expected certificate name
// 4. Optionally check certificate validity dates
// 5. Verify the profile is in active use (no errors/warnings)
// 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.Warn("F5 validation not yet implemented",
"ssl_profile", c.config.SSLProfile)
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: "Certificate deployment validation initiated (stub)",
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
}
+812
View File
@@ -0,0 +1,812 @@
package f5
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/connector/target"
)
// --- Mock F5Client ---
// mockCall records a single method call to the mock F5Client.
type mockCall struct {
Method string
Args []string
}
// mockF5Client records all calls and returns configurable responses.
type mockF5Client struct {
calls []mockCall
// Configurable responses per method
authenticateErr error
authenticateCount int // tracks number of Authenticate calls
uploadFileErr error
uploadFileErrOn string // only error when filename contains this substring
installCertErr error
installCertErrOn string
installKeyErr error
createTransactionID string
createTransactionErr error
commitTransactionErr error
updateSSLProfileErr error
getSSLProfileResult *SSLProfileInfo
getSSLProfileErr error
deleteCertErr error
deleteKeyErr error
// Track cleanup calls specifically
deletedCerts []string
deletedKeys []string
}
func newMockF5Client() *mockF5Client {
return &mockF5Client{
createTransactionID: "12345",
}
}
func (m *mockF5Client) Authenticate(ctx context.Context) error {
m.calls = append(m.calls, mockCall{Method: "Authenticate"})
m.authenticateCount++
return m.authenticateErr
}
func (m *mockF5Client) UploadFile(ctx context.Context, filename string, data []byte) error {
m.calls = append(m.calls, mockCall{Method: "UploadFile", Args: []string{filename, fmt.Sprintf("%d bytes", len(data))}})
if m.uploadFileErrOn != "" && strings.Contains(filename, m.uploadFileErrOn) {
return m.uploadFileErr
}
if m.uploadFileErrOn == "" && m.uploadFileErr != nil {
return m.uploadFileErr
}
return nil
}
func (m *mockF5Client) InstallCert(ctx context.Context, name, localFile string) error {
m.calls = append(m.calls, mockCall{Method: "InstallCert", Args: []string{name, localFile}})
if m.installCertErrOn != "" && strings.Contains(name, m.installCertErrOn) {
return m.installCertErr
}
if m.installCertErrOn == "" && m.installCertErr != nil {
return m.installCertErr
}
return nil
}
func (m *mockF5Client) InstallKey(ctx context.Context, name, localFile string) error {
m.calls = append(m.calls, mockCall{Method: "InstallKey", Args: []string{name, localFile}})
return m.installKeyErr
}
func (m *mockF5Client) CreateTransaction(ctx context.Context) (string, error) {
m.calls = append(m.calls, mockCall{Method: "CreateTransaction"})
return m.createTransactionID, m.createTransactionErr
}
func (m *mockF5Client) CommitTransaction(ctx context.Context, transID string) error {
m.calls = append(m.calls, mockCall{Method: "CommitTransaction", Args: []string{transID}})
return m.commitTransactionErr
}
func (m *mockF5Client) UpdateSSLProfile(ctx context.Context, partition, profile string, certName, keyName, chainName string, transID string) error {
m.calls = append(m.calls, mockCall{Method: "UpdateSSLProfile", Args: []string{partition, profile, certName, keyName, chainName, transID}})
return m.updateSSLProfileErr
}
func (m *mockF5Client) GetSSLProfile(ctx context.Context, partition, profile string) (*SSLProfileInfo, error) {
m.calls = append(m.calls, mockCall{Method: "GetSSLProfile", Args: []string{partition, profile}})
return m.getSSLProfileResult, m.getSSLProfileErr
}
func (m *mockF5Client) DeleteCert(ctx context.Context, partition, name string) error {
m.calls = append(m.calls, mockCall{Method: "DeleteCert", Args: []string{partition, name}})
m.deletedCerts = append(m.deletedCerts, name)
return m.deleteCertErr
}
func (m *mockF5Client) DeleteKey(ctx context.Context, partition, name string) error {
m.calls = append(m.calls, mockCall{Method: "DeleteKey", Args: []string{partition, name}})
m.deletedKeys = append(m.deletedKeys, name)
return m.deleteKeyErr
}
// hasCalled returns true if the mock received a call to the given method.
func (m *mockF5Client) hasCalled(method string) bool {
for _, c := range m.calls {
if c.Method == method {
return true
}
}
return false
}
// callCount returns the number of times a method was called.
func (m *mockF5Client) callCount(method string) int {
count := 0
for _, c := range m.calls {
if c.Method == method {
count++
}
}
return count
}
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
}
// --- ValidateConfig tests ---
func TestValidateConfig(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mock := newMockF5Client()
cfg := &Config{Host: "f5.test.com", Username: "admin", Password: "secret", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
rawConfig, _ := json.Marshal(map[string]interface{}{
"host": "f5.test.com",
"username": "admin",
"password": "secret",
"ssl_profile": "myprofile",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
if !mock.hasCalled("Authenticate") {
t.Error("expected Authenticate to be called")
}
})
t.Run("DefaultsApplied", func(t *testing.T) {
mock := newMockF5Client()
cfg := &Config{}
conn := NewWithClient(cfg, testLogger(), mock)
rawConfig, _ := json.Marshal(map[string]interface{}{
"host": "f5.test.com",
"username": "admin",
"password": "secret",
"ssl_profile": "myprofile",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
// Check defaults were applied
if conn.config.Port != 443 {
t.Errorf("expected port 443, got %d", conn.config.Port)
}
if conn.config.Partition != "Common" {
t.Errorf("expected partition Common, got %s", conn.config.Partition)
}
if conn.config.Timeout != 30 {
t.Errorf("expected timeout 30, got %d", conn.config.Timeout)
}
})
t.Run("InvalidJSON", func(t *testing.T) {
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
err := conn.ValidateConfig(context.Background(), json.RawMessage(`{invalid}`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "invalid F5 config") {
t.Errorf("expected 'invalid F5 config' in error, got: %v", err)
}
})
t.Run("MissingHost", func(t *testing.T) {
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
rawConfig, _ := json.Marshal(map[string]string{
"username": "admin", "password": "secret", "ssl_profile": "prof",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err == nil || !strings.Contains(err.Error(), "host is required") {
t.Errorf("expected 'host is required', got: %v", err)
}
})
t.Run("MissingUsername", func(t *testing.T) {
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
rawConfig, _ := json.Marshal(map[string]string{
"host": "f5.test.com", "password": "secret", "ssl_profile": "prof",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err == nil || !strings.Contains(err.Error(), "username is required") {
t.Errorf("expected 'username is required', got: %v", err)
}
})
t.Run("MissingPassword", func(t *testing.T) {
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
rawConfig, _ := json.Marshal(map[string]string{
"host": "f5.test.com", "username": "admin", "ssl_profile": "prof",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err == nil || !strings.Contains(err.Error(), "password is required") {
t.Errorf("expected 'password is required', got: %v", err)
}
})
t.Run("MissingSSLProfile", func(t *testing.T) {
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
rawConfig, _ := json.Marshal(map[string]string{
"host": "f5.test.com", "username": "admin", "password": "secret",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err == nil || !strings.Contains(err.Error(), "ssl_profile is required") {
t.Errorf("expected 'ssl_profile is required', got: %v", err)
}
})
t.Run("InvalidPort", func(t *testing.T) {
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
rawConfig, _ := json.Marshal(map[string]interface{}{
"host": "f5.test.com", "username": "admin", "password": "secret",
"ssl_profile": "prof", "port": 70000,
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err == nil || !strings.Contains(err.Error(), "port must be between") {
t.Errorf("expected port range error, got: %v", err)
}
})
t.Run("AuthFailure", func(t *testing.T) {
mock := newMockF5Client()
mock.authenticateErr = fmt.Errorf("connection refused")
conn := NewWithClient(&Config{}, testLogger(), mock)
rawConfig, _ := json.Marshal(map[string]string{
"host": "f5.test.com", "username": "admin", "password": "bad",
"ssl_profile": "prof",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err == nil || !strings.Contains(err.Error(), "authentication failed") {
t.Errorf("expected auth failure error, got: %v", err)
}
})
t.Run("InvalidPartitionChars", func(t *testing.T) {
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
rawConfig, _ := json.Marshal(map[string]string{
"host": "f5.test.com", "username": "admin", "password": "secret",
"ssl_profile": "prof", "partition": "Common; rm -rf /",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err == nil || !strings.Contains(err.Error(), "partition contains invalid characters") {
t.Errorf("expected partition validation error, got: %v", err)
}
})
t.Run("InvalidSSLProfileChars", func(t *testing.T) {
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
rawConfig, _ := json.Marshal(map[string]string{
"host": "f5.test.com", "username": "admin", "password": "secret",
"ssl_profile": "prof; echo pwned",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err == nil || !strings.Contains(err.Error(), "ssl_profile contains invalid characters") {
t.Errorf("expected ssl_profile validation error, got: %v", err)
}
})
t.Run("InvalidHostChars", func(t *testing.T) {
conn := NewWithClient(&Config{}, testLogger(), newMockF5Client())
rawConfig, _ := json.Marshal(map[string]string{
"host": "f5.test.com/../../etc/passwd", "username": "admin",
"password": "secret", "ssl_profile": "prof",
})
err := conn.ValidateConfig(context.Background(), rawConfig)
if err == nil || !strings.Contains(err.Error(), "host contains invalid characters") {
t.Errorf("expected host validation error, got: %v", err)
}
})
}
// --- DeployCertificate tests ---
const testCertPEM = `-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIRAJ1gCL7hBmSj6g0gYOr2FzMwCgYIKoZIzj0EAwIwEjEQ
MA4GA1UEChMHY2VydGN0bDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBa
MBIxEDAOBgNVBAoTB2NlcnRjdGwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQr
H2kMjsgP+FZuyMjJLNfewN0EDkN0s4Lz2Y1IqFqD8DlGN3zI3lPQ7hGdQbiCklPk
1YXNmfmI6L2JKxB/d9Gxo1cwVTAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYI
KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBQAAAAAAAAAAAAAAAAA
AAAAADAKBggqhkjOPQQDAgNIADBFAiEA4JIlRKL22y6c2JGwVtM60z2bGm9Lb9rq
3BSSLE8xF3UCIGSKd9bP0BBFIO20daxEP7g3/kTSSYpNMIG6yc6acdHH
-----END CERTIFICATE-----`
const testKeyPEM = `-----BEGIN EC PRIVATE KEY-----
MHQCAQEEIKj7N0fDjLaI9bGmJ/TY3PBvIxwclLOPIdOi6yWI2B5CoAcGBSuBBAAi
oWQDYgAEhLS0ynMvDJH5o0F5e6jVnXOBqRT2bHkVxQng+eqaXdY3gJoFIIxvR/q0
Vy4p3LZFQsKQfBwt3A8LLvOJY6E8bF4MNPrn0O1bQkeMjb8tSxdKfH0bARJdllD
h9oAPTR1
-----END EC PRIVATE KEY-----`
const testChainPEM = `-----BEGIN CERTIFICATE-----
MIIBYzCCAQmgAwIBAgIRAKR1G0hS1jBOQH2VtNTzpHowCgYIKoZIzj0EAwIwEjEQ
MA4GA1UEChMHY2VydGN0bDAeFw0yNTAxMDEwMDAwMDBaFw0yNjAxMDEwMDAwMDBa
MBIxEDAOBgNVBAoTB2NlcnRjdGwwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASE
tLTKcy8MkfmjQXl7qNWdc4GpFPZseRXFCeD56ppd1jeAmgUgjG9H+rRXLinctkVC
wpB8HC3cDwsu84ljoTxso0IwQDAOBgNVHQ8BAf8EBAMCAoQwDwYDVR0TAQH/BAUw
AwEB/zAdBgNVHQ4EFgQUAAAAAAAAAAAAAAAAAAAAAAAwCgYIKoZIzj0EAwIDSAAw
RQIhAJ2K5VVTBiWBrZgdxNthZ7FEqrpNL9LiuD3bWx0xCaoAAiAh9+2p4PQmNuqN
R7kSqe/p0W0VnFx1nOJz/sDyPM+2qg==
-----END CERTIFICATE-----`
func TestDeployCertificate(t *testing.T) {
t.Run("FullSuccessWithChain", func(t *testing.T) {
mock := newMockF5Client()
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{
CertPEM: testCertPEM,
KeyPEM: testKeyPEM,
ChainPEM: testChainPEM,
}
result, err := conn.DeployCertificate(context.Background(), request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("expected success, got: %s", result.Message)
}
// Verify call sequence
if !mock.hasCalled("Authenticate") {
t.Error("expected Authenticate call")
}
if mock.callCount("UploadFile") != 3 {
t.Errorf("expected 3 UploadFile calls (cert, key, chain), got %d", mock.callCount("UploadFile"))
}
if mock.callCount("InstallCert") != 2 { // cert + chain
t.Errorf("expected 2 InstallCert calls (cert + chain), got %d", mock.callCount("InstallCert"))
}
if mock.callCount("InstallKey") != 1 {
t.Errorf("expected 1 InstallKey call, got %d", mock.callCount("InstallKey"))
}
if !mock.hasCalled("CreateTransaction") {
t.Error("expected CreateTransaction call")
}
if !mock.hasCalled("UpdateSSLProfile") {
t.Error("expected UpdateSSLProfile call")
}
if !mock.hasCalled("CommitTransaction") {
t.Error("expected CommitTransaction call")
}
// Verify metadata
if result.Metadata["host"] != "f5.test.com" {
t.Errorf("expected host f5.test.com in metadata, got %s", result.Metadata["host"])
}
if result.Metadata["partition"] != "Common" {
t.Errorf("expected partition Common in metadata, got %s", result.Metadata["partition"])
}
if result.Metadata["ssl_profile"] != "myprofile" {
t.Errorf("expected ssl_profile myprofile in metadata, got %s", result.Metadata["ssl_profile"])
}
if result.Metadata["cert_object_name"] == "" {
t.Error("expected cert_object_name in metadata")
}
if result.Metadata["duration_ms"] == "" {
t.Error("expected duration_ms in metadata")
}
})
t.Run("SuccessWithoutChain", func(t *testing.T) {
mock := newMockF5Client()
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{
CertPEM: testCertPEM,
KeyPEM: testKeyPEM,
}
result, err := conn.DeployCertificate(context.Background(), request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("expected success, got: %s", result.Message)
}
// Should only upload cert + key (no chain)
if mock.callCount("UploadFile") != 2 {
t.Errorf("expected 2 UploadFile calls, got %d", mock.callCount("UploadFile"))
}
if mock.callCount("InstallCert") != 1 { // only cert, no chain
t.Errorf("expected 1 InstallCert call (cert only), got %d", mock.callCount("InstallCert"))
}
if result.Metadata["chain_object_name"] != "" {
t.Errorf("expected empty chain_object_name, got %s", result.Metadata["chain_object_name"])
}
})
t.Run("MissingKeyPEM", func(t *testing.T) {
mock := newMockF5Client()
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{
CertPEM: testCertPEM,
}
result, err := conn.DeployCertificate(context.Background(), request)
if err == nil {
t.Fatal("expected error for missing KeyPEM")
}
if result.Success {
t.Error("expected Success=false")
}
if !strings.Contains(err.Error(), "KeyPEM") {
t.Errorf("expected KeyPEM in error, got: %v", err)
}
})
t.Run("AuthFailure", func(t *testing.T) {
mock := newMockF5Client()
mock.authenticateErr = fmt.Errorf("connection refused")
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "bad", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
result, err := conn.DeployCertificate(context.Background(), request)
if err == nil {
t.Fatal("expected error for auth failure")
}
if result.Success {
t.Error("expected Success=false")
}
if !strings.Contains(err.Error(), "authentication failed") {
t.Errorf("expected auth failure in error, got: %v", err)
}
})
t.Run("CertUploadFailure", func(t *testing.T) {
mock := newMockF5Client()
mock.uploadFileErr = fmt.Errorf("upload timeout")
mock.uploadFileErrOn = "cert"
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
_, err := conn.DeployCertificate(context.Background(), request)
if err == nil {
t.Fatal("expected error for cert upload failure")
}
// No cleanup needed — nothing installed yet
if len(mock.deletedCerts) > 0 || len(mock.deletedKeys) > 0 {
t.Error("expected no cleanup calls when upload fails before install")
}
})
t.Run("CertInstallFailure", func(t *testing.T) {
mock := newMockF5Client()
mock.installCertErr = fmt.Errorf("install failed")
// Don't set installCertErrOn — all InstallCert calls will fail
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
_, err := conn.DeployCertificate(context.Background(), request)
if err == nil {
t.Fatal("expected error for cert install failure")
}
if !strings.Contains(err.Error(), "cert crypto object") {
t.Errorf("expected cert install error, got: %v", err)
}
// No cleanup — cert install failed so nothing to clean up
// (the cert object wasn't successfully installed)
})
t.Run("KeyInstallFailure_CleansCert", func(t *testing.T) {
mock := newMockF5Client()
mock.installKeyErr = fmt.Errorf("key install failed")
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
_, err := conn.DeployCertificate(context.Background(), request)
if err == nil {
t.Fatal("expected error for key install failure")
}
// Should have cleaned up the cert that was installed
if len(mock.deletedCerts) != 1 {
t.Errorf("expected 1 cert cleanup, got %d", len(mock.deletedCerts))
}
})
t.Run("TransactionCreateFailure_CleansObjects", func(t *testing.T) {
mock := newMockF5Client()
mock.createTransactionErr = fmt.Errorf("transaction service unavailable")
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
_, err := conn.DeployCertificate(context.Background(), request)
if err == nil {
t.Fatal("expected error for transaction create failure")
}
// Should clean up cert + key
if len(mock.deletedCerts) != 1 {
t.Errorf("expected 1 cert cleanup, got %d", len(mock.deletedCerts))
}
if len(mock.deletedKeys) != 1 {
t.Errorf("expected 1 key cleanup, got %d", len(mock.deletedKeys))
}
})
t.Run("ProfileUpdateFailure_CleansObjects", func(t *testing.T) {
mock := newMockF5Client()
mock.updateSSLProfileErr = fmt.Errorf("profile not found")
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "nonexistent"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM, ChainPEM: testChainPEM}
_, err := conn.DeployCertificate(context.Background(), request)
if err == nil {
t.Fatal("expected error for profile update failure")
}
// Should clean up cert + chain + key
if len(mock.deletedCerts) != 2 { // cert + chain
t.Errorf("expected 2 cert cleanups (cert + chain), got %d", len(mock.deletedCerts))
}
if len(mock.deletedKeys) != 1 {
t.Errorf("expected 1 key cleanup, got %d", len(mock.deletedKeys))
}
})
t.Run("CommitFailure_CleansObjects", func(t *testing.T) {
mock := newMockF5Client()
mock.commitTransactionErr = fmt.Errorf("transaction validation failed")
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
_, err := conn.DeployCertificate(context.Background(), request)
if err == nil {
t.Fatal("expected error for commit failure")
}
if !strings.Contains(err.Error(), "commit") {
t.Errorf("expected commit error, got: %v", err)
}
// Should clean up installed objects
if len(mock.deletedCerts) < 1 {
t.Error("expected cert cleanup on commit failure")
}
if len(mock.deletedKeys) < 1 {
t.Error("expected key cleanup on commit failure")
}
})
t.Run("MetadataVerification", func(t *testing.T) {
mock := newMockF5Client()
cfg := &Config{Host: "bigip.prod.internal", Port: 8443, Username: "admin", Password: "secret", Partition: "Production", SSLProfile: "api-ssl"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.DeploymentRequest{CertPEM: testCertPEM, KeyPEM: testKeyPEM}
result, err := conn.DeployCertificate(context.Background(), request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if result.Metadata["host"] != "bigip.prod.internal" {
t.Errorf("expected host bigip.prod.internal, got %s", result.Metadata["host"])
}
if result.Metadata["partition"] != "Production" {
t.Errorf("expected partition Production, got %s", result.Metadata["partition"])
}
if result.Metadata["ssl_profile"] != "api-ssl" {
t.Errorf("expected ssl_profile api-ssl, got %s", result.Metadata["ssl_profile"])
}
if !strings.HasPrefix(result.Metadata["cert_object_name"], "certctl-cert-") {
t.Errorf("expected cert_object_name to start with certctl-cert-, got %s", result.Metadata["cert_object_name"])
}
if result.TargetAddress != "bigip.prod.internal:8443" {
t.Errorf("expected target address bigip.prod.internal:8443, got %s", result.TargetAddress)
}
})
}
// --- ValidateDeployment tests ---
func TestValidateDeployment(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mock := newMockF5Client()
mock.getSSLProfileResult = &SSLProfileInfo{
Name: "myprofile",
Cert: "/Common/certctl-cert-1234567890",
Key: "/Common/certctl-key-1234567890",
Chain: "/Common/certctl-chain-1234567890",
}
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.ValidationRequest{
CertificateID: "mc-test-cert",
Serial: "abc123",
}
result, err := conn.ValidateDeployment(context.Background(), request)
if err != nil {
t.Fatalf("ValidateDeployment failed: %v", err)
}
if !result.Valid {
t.Fatalf("expected valid, got: %s", result.Message)
}
if result.Metadata["current_cert"] != "/Common/certctl-cert-1234567890" {
t.Errorf("expected cert in metadata, got %s", result.Metadata["current_cert"])
}
})
t.Run("ProfileNotFound", func(t *testing.T) {
mock := newMockF5Client()
mock.getSSLProfileErr = fmt.Errorf("object not found (404)")
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "nonexistent"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"}
result, err := conn.ValidateDeployment(context.Background(), request)
if err == nil {
t.Fatal("expected error for profile not found")
}
if result.Valid {
t.Error("expected Valid=false")
}
})
t.Run("AuthFailure", func(t *testing.T) {
mock := newMockF5Client()
mock.authenticateErr = fmt.Errorf("auth failed")
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "bad", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"}
_, err := conn.ValidateDeployment(context.Background(), request)
if err == nil {
t.Fatal("expected error for auth failure")
}
if !strings.Contains(err.Error(), "authentication failed") {
t.Errorf("expected auth failure error, got: %v", err)
}
})
t.Run("UnexpectedCert_StillValid", func(t *testing.T) {
mock := newMockF5Client()
mock.getSSLProfileResult = &SSLProfileInfo{
Name: "myprofile",
Cert: "/Common/some-other-cert",
Key: "/Common/some-other-key",
}
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"}
result, err := conn.ValidateDeployment(context.Background(), request)
if err != nil {
t.Fatalf("ValidateDeployment failed: %v", err)
}
// We report what's there — it's valid (profile exists with a cert)
if !result.Valid {
t.Error("expected Valid=true (profile has a cert)")
}
if result.Metadata["current_cert"] != "/Common/some-other-cert" {
t.Errorf("expected current cert reported, got %s", result.Metadata["current_cert"])
}
})
t.Run("EmptyCertField", func(t *testing.T) {
mock := newMockF5Client()
mock.getSSLProfileResult = &SSLProfileInfo{
Name: "myprofile",
Cert: "",
Key: "",
}
cfg := &Config{Host: "f5.test.com", Port: 443, Username: "admin", Password: "secret", Partition: "Common", SSLProfile: "myprofile"}
conn := NewWithClient(cfg, testLogger(), mock)
request := target.ValidationRequest{CertificateID: "mc-test", Serial: "abc"}
result, err := conn.ValidateDeployment(context.Background(), request)
if err == nil {
t.Fatal("expected error for empty cert field")
}
if result.Valid {
t.Error("expected Valid=false")
}
if !strings.Contains(err.Error(), "no certificate configured") {
t.Errorf("expected 'no certificate configured' error, got: %v", err)
}
})
}
// --- Helper tests ---
func TestObjectName(t *testing.T) {
name1 := objectName("cert")
name2 := objectName("cert")
if !strings.HasPrefix(name1, "certctl-cert-") {
t.Errorf("expected prefix certctl-cert-, got %s", name1)
}
// Nanosecond timestamps should produce different names
if name1 == name2 {
t.Error("expected unique names from nanosecond timestamps")
}
}
func TestPartitionPath(t *testing.T) {
path := partitionPath("Common", "certctl-cert-123")
if path != "/Common/certctl-cert-123" {
t.Errorf("expected /Common/certctl-cert-123, got %s", path)
}
path = partitionPath("Production", "my-cert")
if path != "/Production/my-cert" {
t.Errorf("expected /Production/my-cert, got %s", path)
}
}
func TestCleanup_MixedResults(t *testing.T) {
mock := newMockF5Client()
mock.deleteCertErr = fmt.Errorf("cert in use") // cert delete fails
// key delete succeeds (nil error)
cfg := &Config{Host: "f5.test.com", Port: 443, Partition: "Common"}
conn := NewWithClient(cfg, testLogger(), mock)
// Should not panic and should attempt all deletions
conn.cleanupCryptoObjects(context.Background(), "Common",
[]string{"cert1", "cert2"},
[]string{"key1"},
)
// Both cert deletes attempted despite errors
if len(mock.deletedCerts) != 2 {
t.Errorf("expected 2 cert delete attempts, got %d", len(mock.deletedCerts))
}
if len(mock.deletedKeys) != 1 {
t.Errorf("expected 1 key delete attempt, got %d", len(mock.deletedKeys))
}
}
func TestCleanup_EmptyNames(t *testing.T) {
mock := newMockF5Client()
cfg := &Config{Host: "f5.test.com", Port: 443, Partition: "Common"}
conn := NewWithClient(cfg, testLogger(), mock)
// Empty names should be skipped
conn.cleanupCryptoObjects(context.Background(), "Common",
[]string{"", "cert1", ""},
[]string{"", ""},
)
if len(mock.deletedCerts) != 1 {
t.Errorf("expected 1 cert delete (skipping empties), got %d", len(mock.deletedCerts))
}
if len(mock.deletedKeys) != 0 {
t.Errorf("expected 0 key deletes (all empty), got %d", len(mock.deletedKeys))
}
}
func TestNew_NilConfig(t *testing.T) {
_, err := New(nil, testLogger())
if err == nil {
t.Fatal("expected error for nil config")
}
if !strings.Contains(err.Error(), "config is required") {
t.Errorf("expected 'config is required' error, got: %v", err)
}
}
+1
View File
@@ -72,6 +72,7 @@ const (
IssuerTypeVault IssuerType = "VaultPKI"
IssuerTypeDigiCert IssuerType = "DigiCert"
IssuerTypeSectigo IssuerType = "Sectigo"
IssuerTypeGoogleCAS IssuerType = "GoogleCAS"
)
// TargetType represents the type of deployment target.
+2 -1
View File
@@ -46,7 +46,8 @@ INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VA
('iss-openssl', 'Custom OpenSSL CA', 'OpenSSL', '{"sign_script": "/opt/ca/sign.sh", "timeout_seconds": 30}', false, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
('iss-vault', 'HashiCorp Vault PKI', 'VaultPKI', '{"addr": "https://vault.internal:8200", "mount": "pki", "role": "web-certs", "ttl": "8760h"}', true, NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days'),
('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days'),
('iss-sectigo', 'Sectigo SCM', 'Sectigo', '{"base_url": "https://cert-manager.com/api", "cert_type": 423, "term": 365}', true, NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days')
('iss-sectigo', 'Sectigo SCM', 'Sectigo', '{"base_url": "https://cert-manager.com/api", "cert_type": 423, "term": 365}', true, NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
('iss-googlecas','Google CAS', 'GoogleCAS', '{"project": "demo-project", "location": "us-central1", "ca_pool": "demo-pool"}', false, NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
+13
View File
@@ -135,6 +135,19 @@ export const issuerTypes: IssuerTypeConfig[] = [
{ key: 'base_url', label: 'Base URL', required: false, placeholder: 'https://cert-manager.com/api' },
],
},
{
id: 'GoogleCAS',
name: 'Google CAS',
description: 'Google Cloud Certificate Authority Service \u2014 managed private CA on GCP',
icon: '\u2601\uFE0F',
configFields: [
{ key: 'project', label: 'GCP Project ID', required: true, placeholder: 'my-gcp-project' },
{ key: 'location', label: 'Location', required: true, placeholder: 'us-central1' },
{ key: 'ca_pool', label: 'CA Pool', required: true, placeholder: 'my-ca-pool' },
{ key: 'credentials', label: 'Service Account JSON Path', required: true, placeholder: '/path/to/credentials.json', sensitive: true },
{ key: 'ttl', label: 'Default TTL', required: false, placeholder: '8760h' },
],
},
{
id: 'entrust',
name: 'Entrust',
+8 -3
View File
@@ -32,7 +32,7 @@ const TARGET_TYPES = [
{ value: 'envoy', label: 'Envoy', description: 'File-based deployment — writes cert/key to watched directory. Optional SDS file generation.' },
{ value: 'postfix', label: 'Postfix', description: 'Postfix MTA — file write + postfix reload' },
{ value: 'dovecot', label: 'Dovecot', description: 'Dovecot IMAP/POP3 — file write + doveadm reload' },
{ value: 'f5_bigip', label: 'F5 BIG-IP', description: 'iControl REST via proxy agent (V3 implementation)' },
{ value: 'f5_bigip', label: 'F5 BIG-IP', description: 'iControl REST — cert upload, SSL profile update via proxy agent' },
{ value: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' },
];
@@ -88,9 +88,14 @@ const CONFIG_FIELDS: Record<string, { key: string; label: string; placeholder: s
{ key: 'validate_command', label: 'Validate Command', placeholder: 'doveconf -n' },
],
f5_bigip: [
{ key: 'management_ip', label: 'Management IP', placeholder: '192.168.1.100', required: true },
{ key: 'host', label: 'Management Host', placeholder: 'f5.internal.example.com', required: true },
{ key: 'port', label: 'Management Port', placeholder: '443' },
{ key: 'username', label: 'Username', placeholder: 'admin', required: true },
{ key: 'password', label: 'Password', placeholder: 'F5 admin password', required: true },
{ key: 'partition', label: 'Partition', placeholder: 'Common' },
{ key: 'proxy_agent_id', label: 'Proxy Agent ID', placeholder: 'agent-f5-proxy' },
{ key: 'ssl_profile', label: 'SSL Profile', placeholder: 'clientssl_api', required: true },
{ key: 'insecure', label: 'Skip TLS Verify', placeholder: 'true (default)' },
{ key: 'timeout', label: 'Timeout (seconds)', placeholder: '30' },
],
iis: [
{ key: 'site_name', label: 'IIS Site Name', placeholder: 'Default Web Site', required: true },