Skip to content

Commit bcca88b

Browse files
ericfitzclaude
andcommitted
feat(terraform): add Let's Encrypt certificate automation for OCI
Add automatic certificate provisioning using OCI Functions: - New Terraform module terraform/modules/certificates/oci/ - OCI Function (certmgr) for ACME DNS-01 challenges - Vault secrets for certificate/key/ACME account storage - IAM policies for DNS, Vault, and Load Balancer access - Makefile targets for function build/deploy/invoke Certificate lifecycle: daily renewal checks, auto-renewal when certificate expires within configurable threshold (default 30 days). Uses Resource Principal auth - no credentials in config. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a543c14 commit bcca88b

File tree

19 files changed

+1686
-6
lines changed

19 files changed

+1686
-6
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
!static/**
4343
!wstest/
4444
!wstest/**
45+
!functions/
46+
!functions/**
4547
!test/
4648
!test/**
4749
!.claude/
@@ -94,6 +96,9 @@ test/outputs/
9496
test/deprecated/
9597
test/tools/wstest/wstest
9698

99+
# Built function binaries
100+
functions/certmgr/certmgr
101+
97102
# Oracle/OCI (contains sensitive credentials)
98103
wallet/
99104
Wallet_*.zip

Makefile

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,56 @@ start-containers-environment:
10791079
# Shorthand for all container operations
10801080
build-containers-all: build-containers report-containers
10811081

1082+
# ============================================================================
1083+
# OCI FUNCTIONS - Certificate Manager
1084+
# ============================================================================
1085+
1086+
.PHONY: fn-check fn-build-certmgr fn-deploy-certmgr fn-invoke-certmgr fn-logs-certmgr
1087+
1088+
# Check if Fn CLI is installed
1089+
fn-check:
1090+
@command -v fn >/dev/null 2>&1 || { \
1091+
echo -e "$(RED)[ERROR]$(NC) Fn CLI is not installed."; \
1092+
echo -e "$(BLUE)[INFO]$(NC) Install with: brew install fn"; \
1093+
exit 1; \
1094+
}
1095+
1096+
# Build the certificate manager function
1097+
fn-build-certmgr: fn-check ## Build the certificate manager OCI function
1098+
$(call log_info,Building certificate manager function...)
1099+
@cd functions/certmgr && fn build
1100+
$(call log_success,Certificate manager function built successfully)
1101+
1102+
# Deploy the certificate manager function to OCI
1103+
fn-deploy-certmgr: fn-check ## Deploy certificate manager function to OCI (requires OCI config)
1104+
$(call log_info,Deploying certificate manager function...)
1105+
@if [ -z "$(FN_APP)" ]; then \
1106+
echo -e "$(RED)[ERROR]$(NC) FN_APP environment variable not set."; \
1107+
echo -e "$(BLUE)[INFO]$(NC) Set FN_APP to the OCI Function Application name"; \
1108+
exit 1; \
1109+
fi
1110+
@cd functions/certmgr && fn deploy --app $(FN_APP)
1111+
$(call log_success,Certificate manager function deployed)
1112+
1113+
# Invoke the certificate manager function manually (for testing)
1114+
fn-invoke-certmgr: fn-check ## Invoke certificate manager function manually for testing
1115+
$(call log_info,Invoking certificate manager function...)
1116+
@if [ -z "$(FN_APP)" ]; then \
1117+
echo -e "$(RED)[ERROR]$(NC) FN_APP environment variable not set."; \
1118+
exit 1; \
1119+
fi
1120+
@fn invoke $(FN_APP) certmgr
1121+
$(call log_success,Function invoked)
1122+
1123+
# View certificate manager function logs
1124+
fn-logs-certmgr: fn-check ## View certificate manager function logs
1125+
$(call log_info,Fetching certificate manager function logs...)
1126+
@if [ -z "$(FN_APP)" ]; then \
1127+
echo -e "$(RED)[ERROR]$(NC) FN_APP environment variable not set."; \
1128+
exit 1; \
1129+
fi
1130+
@fn logs $(FN_APP) certmgr
1131+
10821132
# ============================================================================
10831133
# TERRAFORM INFRASTRUCTURE MANAGEMENT
10841134
# ============================================================================
@@ -1630,6 +1680,12 @@ help:
16301680
@echo " start-containers-environment - Start development with containers"
16311681
@echo " build-containers-all - Run full container build and report"
16321682
@echo ""
1683+
@echo "OCI Functions (Certificate Manager):"
1684+
@echo " fn-build-certmgr - Build the certificate manager function"
1685+
@echo " fn-deploy-certmgr - Deploy certificate manager to OCI"
1686+
@echo " fn-invoke-certmgr - Invoke certificate manager for testing"
1687+
@echo " fn-logs-certmgr - View certificate manager logs"
1688+
@echo ""
16331689
@echo "Terraform Infrastructure Management:"
16341690
@echo " tf-init - Initialize Terraform (TF_ENV=oci-free-tier)"
16351691
@echo " tf-validate - Validate Terraform configuration"

functions/certmgr/acme.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"crypto"
6+
"crypto/ecdsa"
7+
"crypto/elliptic"
8+
"crypto/rand"
9+
"crypto/x509"
10+
"crypto/x509/pkix"
11+
"encoding/pem"
12+
"fmt"
13+
"time"
14+
15+
"golang.org/x/crypto/acme"
16+
)
17+
18+
// ACMEClient wraps the ACME client for Let's Encrypt operations
19+
type ACMEClient struct {
20+
client *acme.Client
21+
directory string
22+
email string
23+
}
24+
25+
// Certificate represents a TLS certificate with its private key
26+
type Certificate struct {
27+
CertificatePEM string
28+
PrivateKeyPEM string
29+
IssuerPEM string
30+
NotAfter time.Time
31+
}
32+
33+
// NewACMEClient creates a new ACME client
34+
func NewACMEClient(directory, email string, accountKey crypto.Signer) *ACMEClient {
35+
client := &acme.Client{
36+
Key: accountKey,
37+
DirectoryURL: directory,
38+
}
39+
40+
return &ACMEClient{
41+
client: client,
42+
directory: directory,
43+
email: email,
44+
}
45+
}
46+
47+
// GenerateAccountKey generates a new ECDSA private key for the ACME account
48+
func GenerateAccountKey() (*ecdsa.PrivateKey, error) {
49+
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
50+
}
51+
52+
// EncodeAccountKey encodes an ECDSA private key to PEM format
53+
func EncodeAccountKey(key *ecdsa.PrivateKey) (string, error) {
54+
der, err := x509.MarshalECPrivateKey(key)
55+
if err != nil {
56+
return "", fmt.Errorf("failed to marshal account key: %w", err)
57+
}
58+
59+
block := &pem.Block{
60+
Type: "EC PRIVATE KEY",
61+
Bytes: der,
62+
}
63+
64+
return string(pem.EncodeToMemory(block)), nil
65+
}
66+
67+
// DecodeAccountKey decodes a PEM-encoded ECDSA private key
68+
func DecodeAccountKey(pemData string) (*ecdsa.PrivateKey, error) {
69+
block, _ := pem.Decode([]byte(pemData))
70+
if block == nil {
71+
return nil, fmt.Errorf("failed to decode PEM block")
72+
}
73+
74+
return x509.ParseECPrivateKey(block.Bytes)
75+
}
76+
77+
// RegisterAccount registers or retrieves an existing ACME account
78+
func (c *ACMEClient) RegisterAccount(ctx context.Context) error {
79+
account := &acme.Account{
80+
Contact: []string{"mailto:" + c.email},
81+
}
82+
83+
_, err := c.client.Register(ctx, account, acme.AcceptTOS)
84+
if err != nil && err != acme.ErrAccountAlreadyExists {
85+
return fmt.Errorf("failed to register ACME account: %w", err)
86+
}
87+
88+
return nil
89+
}
90+
91+
// RequestCertificate requests a new certificate using DNS-01 challenge
92+
// It returns the DNS challenge token that must be set as a TXT record
93+
func (c *ACMEClient) RequestCertificate(ctx context.Context, domain string) (*acme.Authorization, *acme.Challenge, error) {
94+
// Create a new order
95+
order, err := c.client.AuthorizeOrder(ctx, acme.DomainIDs(domain))
96+
if err != nil {
97+
return nil, nil, fmt.Errorf("failed to create order: %w", err)
98+
}
99+
100+
// Get the authorization
101+
if len(order.AuthzURLs) == 0 {
102+
return nil, nil, fmt.Errorf("no authorization URLs in order")
103+
}
104+
105+
auth, err := c.client.GetAuthorization(ctx, order.AuthzURLs[0])
106+
if err != nil {
107+
return nil, nil, fmt.Errorf("failed to get authorization: %w", err)
108+
}
109+
110+
// Find the DNS-01 challenge
111+
var dnsChallenge *acme.Challenge
112+
for _, ch := range auth.Challenges {
113+
if ch.Type == "dns-01" {
114+
dnsChallenge = ch
115+
break
116+
}
117+
}
118+
119+
if dnsChallenge == nil {
120+
return nil, nil, fmt.Errorf("no DNS-01 challenge found")
121+
}
122+
123+
return auth, dnsChallenge, nil
124+
}
125+
126+
// GetDNSChallengeRecord returns the TXT record value for the DNS-01 challenge
127+
func (c *ACMEClient) GetDNSChallengeRecord(challenge *acme.Challenge) (string, error) {
128+
return c.client.DNS01ChallengeRecord(challenge.Token)
129+
}
130+
131+
// AcceptChallenge notifies the ACME server that the challenge is ready
132+
func (c *ACMEClient) AcceptChallenge(ctx context.Context, challenge *acme.Challenge) error {
133+
_, err := c.client.Accept(ctx, challenge)
134+
return err
135+
}
136+
137+
// WaitForAuthorization waits for the authorization to be valid
138+
func (c *ACMEClient) WaitForAuthorization(ctx context.Context, auth *acme.Authorization) error {
139+
_, err := c.client.WaitAuthorization(ctx, auth.URI)
140+
return err
141+
}
142+
143+
// FinalizeCertificate finalizes the order and retrieves the certificate
144+
func (c *ACMEClient) FinalizeCertificate(ctx context.Context, domain string) (*Certificate, error) {
145+
// Generate a new private key for the certificate
146+
certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
147+
if err != nil {
148+
return nil, fmt.Errorf("failed to generate certificate key: %w", err)
149+
}
150+
151+
// Create a CSR
152+
csr := &x509.CertificateRequest{
153+
Subject: pkix.Name{
154+
CommonName: domain,
155+
},
156+
DNSNames: []string{domain},
157+
}
158+
159+
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csr, certKey)
160+
if err != nil {
161+
return nil, fmt.Errorf("failed to create CSR: %w", err)
162+
}
163+
164+
// Create a new order and finalize it
165+
order, err := c.client.AuthorizeOrder(ctx, acme.DomainIDs(domain))
166+
if err != nil {
167+
return nil, fmt.Errorf("failed to create order for finalization: %w", err)
168+
}
169+
170+
// Wait for order to be ready
171+
order, err = c.client.WaitOrder(ctx, order.URI)
172+
if err != nil {
173+
return nil, fmt.Errorf("failed to wait for order: %w", err)
174+
}
175+
176+
// Finalize the order
177+
certChain, _, err := c.client.CreateOrderCert(ctx, order.FinalizeURL, csrDER, true)
178+
if err != nil {
179+
return nil, fmt.Errorf("failed to finalize order: %w", err)
180+
}
181+
182+
// Parse the certificate chain
183+
if len(certChain) == 0 {
184+
return nil, fmt.Errorf("empty certificate chain")
185+
}
186+
187+
// Parse the first certificate to get expiry
188+
cert, err := x509.ParseCertificate(certChain[0])
189+
if err != nil {
190+
return nil, fmt.Errorf("failed to parse certificate: %w", err)
191+
}
192+
193+
// Encode certificate chain to PEM
194+
var certPEM, issuerPEM string
195+
for i, certDER := range certChain {
196+
block := &pem.Block{
197+
Type: "CERTIFICATE",
198+
Bytes: certDER,
199+
}
200+
if i == 0 {
201+
certPEM = string(pem.EncodeToMemory(block))
202+
} else {
203+
issuerPEM += string(pem.EncodeToMemory(block))
204+
}
205+
}
206+
207+
// Encode private key to PEM
208+
keyDER, err := x509.MarshalECPrivateKey(certKey)
209+
if err != nil {
210+
return nil, fmt.Errorf("failed to marshal private key: %w", err)
211+
}
212+
213+
keyBlock := &pem.Block{
214+
Type: "EC PRIVATE KEY",
215+
Bytes: keyDER,
216+
}
217+
keyPEM := string(pem.EncodeToMemory(keyBlock))
218+
219+
return &Certificate{
220+
CertificatePEM: certPEM,
221+
PrivateKeyPEM: keyPEM,
222+
IssuerPEM: issuerPEM,
223+
NotAfter: cert.NotAfter,
224+
}, nil
225+
}

functions/certmgr/config.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strconv"
7+
)
8+
9+
// Config holds the certificate manager configuration
10+
type Config struct {
11+
Domain string
12+
DNSZoneID string
13+
ACMEEmail string
14+
RenewalDays int
15+
LoadBalancerID string
16+
VaultID string
17+
VaultKeyID string
18+
CompartmentID string
19+
NamePrefix string
20+
ACMEDirectory string
21+
DryRun bool
22+
}
23+
24+
// LoadConfig loads configuration from environment variables
25+
func LoadConfig() (*Config, error) {
26+
config := &Config{
27+
Domain: os.Getenv("CERTMGR_DOMAIN"),
28+
DNSZoneID: os.Getenv("CERTMGR_DNS_ZONE_ID"),
29+
ACMEEmail: os.Getenv("CERTMGR_ACME_EMAIL"),
30+
LoadBalancerID: os.Getenv("CERTMGR_LB_ID"),
31+
VaultID: os.Getenv("CERTMGR_VAULT_ID"),
32+
VaultKeyID: os.Getenv("CERTMGR_VAULT_KEY_ID"),
33+
CompartmentID: os.Getenv("CERTMGR_COMPARTMENT_ID"),
34+
NamePrefix: os.Getenv("CERTMGR_NAME_PREFIX"),
35+
ACMEDirectory: os.Getenv("CERTMGR_ACME_DIRECTORY"),
36+
DryRun: os.Getenv("CERTMGR_DRY_RUN") == "true",
37+
}
38+
39+
// Default name prefix
40+
if config.NamePrefix == "" {
41+
config.NamePrefix = "tmi"
42+
}
43+
44+
// Parse renewal days with default
45+
renewalDaysStr := os.Getenv("CERTMGR_RENEWAL_DAYS")
46+
if renewalDaysStr == "" {
47+
config.RenewalDays = 30
48+
} else {
49+
days, err := strconv.Atoi(renewalDaysStr)
50+
if err != nil {
51+
return nil, fmt.Errorf("invalid CERTMGR_RENEWAL_DAYS: %w", err)
52+
}
53+
config.RenewalDays = days
54+
}
55+
56+
// Default ACME directory to staging
57+
if config.ACMEDirectory == "" {
58+
config.ACMEDirectory = "https://acme-staging-v02.api.letsencrypt.org/directory"
59+
}
60+
61+
// Validate required fields
62+
if config.Domain == "" {
63+
return nil, fmt.Errorf("CERTMGR_DOMAIN is required")
64+
}
65+
if config.DNSZoneID == "" {
66+
return nil, fmt.Errorf("CERTMGR_DNS_ZONE_ID is required")
67+
}
68+
if config.ACMEEmail == "" {
69+
return nil, fmt.Errorf("CERTMGR_ACME_EMAIL is required")
70+
}
71+
if config.LoadBalancerID == "" {
72+
return nil, fmt.Errorf("CERTMGR_LB_ID is required")
73+
}
74+
if config.VaultID == "" {
75+
return nil, fmt.Errorf("CERTMGR_VAULT_ID is required")
76+
}
77+
if config.VaultKeyID == "" {
78+
return nil, fmt.Errorf("CERTMGR_VAULT_KEY_ID is required")
79+
}
80+
81+
return config, nil
82+
}

0 commit comments

Comments
 (0)