Skip to content

Commit b4f6a35

Browse files
Merge pull request #3 from esnet/develop
Implement cert revocation, option to configure max cert lifetime
2 parents 83b5a5e + 18151cf commit b4f6a35

6 files changed

Lines changed: 142 additions & 20 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,28 @@ jobs:
5050
- name: Run go vet
5151
run: go vet ./...
5252

53+
test:
54+
name: Run unit tests
55+
runs-on: ubuntu-latest
56+
steps:
57+
- name: Checkout code
58+
uses: actions/checkout@v4
59+
60+
- name: Set up Go
61+
uses: actions/setup-go@v5
62+
with:
63+
go-version: ${{ env.GO_VERSION }}
64+
65+
- name: Install dependencies
66+
run: |
67+
sudo apt-get update
68+
sudo apt-get install -y libpcsclite-dev
69+
70+
- name: Run tests
71+
run: |
72+
make dev
73+
go test -v -race ./...
74+
5375
security-scan:
5476
name: Security Scan
5577
runs-on: ubuntu-latest
@@ -83,7 +105,7 @@ jobs:
83105
build:
84106
name: Build
85107
runs-on: ubuntu-latest
86-
needs: [lint, security-scan]
108+
needs: [lint, security-scan, test]
87109
steps:
88110
- name: Checkout code
89111
uses: actions/checkout@v4
@@ -110,7 +132,7 @@ jobs:
110132
container-build-and-push:
111133
name: Build and Push Container
112134
runs-on: ubuntu-latest
113-
needs: [lint, security-scan]
135+
needs: [lint, security-scan, test]
114136
permissions:
115137
contents: read
116138
packages: write

‎.pre-commit-config.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,12 @@ repos:
77
- id: end-of-file-fixer
88
- id: check-yaml
99
- id: check-added-large-files
10+
11+
- repo: local
12+
hooks:
13+
- id: go-test
14+
name: go test
15+
entry: go test ./externalcas
16+
language: system
17+
pass_filenames: false
18+
types: [go]

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This is a work in progress. Not quite ready for production but will be soon.
1111
TODO
1212

1313
- [x] Move config bits from env vars to `ca.json`
14-
- [ ] Implement Revoke method
14+
- [x] Implement Revoke method
1515
- [x] Re-assess if `GetCertificateAuthority` is a requirement or not
1616
- [ ] Write unit tests
1717
- [ ] Prometheus metrics
@@ -54,6 +54,7 @@ The most important part of the config is this section
5454
"account_email": "",
5555
"eab_kid": "",
5656
"eab_hmac_key": "",
57+
"certlifetime": 30,
5758
"metrics": {
5859
"enabled": true,
5960
"port": 9123

‎ca.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"account_email": "",
1616
"eab_kid": "",
1717
"eab_hmac_key": "",
18+
"certlifetime": 30,
1819
"metrics": {
1920
"enabled": true,
2021
"port": 9123

‎externalcas/external.go‎

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"encoding/pem"
1111
"errors"
1212
"fmt"
13-
"log"
13+
"log/slog"
1414
"net/http"
1515
"sync"
1616
"time"
@@ -42,7 +42,7 @@ type AcmeProxyConfig struct {
4242
HmacKey string `json:"eab_hmac_key"`
4343

4444
// Certificate lifetime in days
45-
ValidFor int `json:"validity,omitempty"`
45+
CertLifetime int `json:"certlifetime,omitempty"`
4646

4747
// Prometheus metrics endpoint
4848
Metrics Metrics `json:"metrics"`
@@ -91,7 +91,7 @@ func (c *ExternalCAS) Type() apiv1.Type {
9191

9292
func (c *ExternalCAS) initClient() error {
9393
c.initOnce.Do(func() {
94-
log.Println("Initializing ACME client...")
94+
slog.Info("initializing ACME client")
9595

9696
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
9797
if err != nil {
@@ -102,7 +102,9 @@ func (c *ExternalCAS) initClient() error {
102102
// Unmarshal EAB config from ca.json
103103
var eab AcmeProxyConfig
104104
if err = json.Unmarshal(c.config, &eab); err != nil {
105-
log.Fatal("Error unmarshalling EAB config from ca.json", err)
105+
slog.Error("failed to unmarshal EAB config", "error", err)
106+
c.initError = fmt.Errorf("failed to unmarshal EAB config: %w", err)
107+
return
106108
}
107109

108110
user := User{
@@ -138,7 +140,7 @@ func (c *ExternalCAS) initClient() error {
138140
}
139141
c.user.Registration = reg
140142

141-
log.Println("ACME client initialized successfully")
143+
slog.Info("ACME client initialized")
142144
})
143145
return c.initError
144146
}
@@ -158,14 +160,14 @@ func (c *ExternalCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*a
158160
return nil, fmt.Errorf("failed to initialize ACME client: %w", err)
159161
}
160162

161-
log.Printf("Processing certificate request for domains: %v", req.CSR.DNSNames)
163+
slog.Info("processing certificate request", "domains", req.CSR.DNSNames)
162164

163165
resultChan := make(chan *certificateResult, 1)
164166

165167
go func() {
166168
defer func() {
167169
if r := recover(); r != nil {
168-
log.Printf("Recovered from panic in processCertificateRequest: %v", r)
170+
slog.Error("recovered from panic in processCertificateRequest", "panic", r)
169171
resultChan <- &certificateResult{
170172
err: fmt.Errorf("internal error: %v", r),
171173
}
@@ -176,7 +178,7 @@ func (c *ExternalCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*a
176178
select {
177179
case resultChan <- result:
178180
case <-ctx.Done():
179-
log.Printf("Certificate request timed out or cancelled for domains: %v", req.CSR.DNSNames)
181+
slog.Warn("certificate request timed out or cancelled", "domains", req.CSR.DNSNames)
180182
}
181183
}()
182184

@@ -192,7 +194,7 @@ func (c *ExternalCAS) CreateCertificate(req *apiv1.CreateCertificateRequest) (*a
192194
}
193195

194196
func (c *ExternalCAS) processCertificateRequest(ctx context.Context, req *apiv1.CreateCertificateRequest) *certificateResult {
195-
log.Printf("Starting certificate request processing for domains: %v", req.CSR.DNSNames)
197+
slog.Debug("starting certificate request processing", "domains", req.CSR.DNSNames)
196198

197199
select {
198200
case <-ctx.Done():
@@ -202,18 +204,31 @@ func (c *ExternalCAS) processCertificateRequest(ctx context.Context, req *apiv1.
202204
default:
203205
}
204206

205-
cert, err := c.client.Certificate.ObtainForCSR(certificate.ObtainForCSRRequest{
206-
CSR: req.CSR,
207-
Bundle: true,
208-
NotAfter: time.Now().Add(1 * 24 * time.Hour),
209-
})
207+
var cfg AcmeProxyConfig
208+
if err := json.Unmarshal(c.config, &cfg); err != nil {
209+
return &certificateResult{
210+
err: fmt.Errorf("failed to parse acmeproxy config: %v", err),
211+
}
212+
}
213+
214+
// Build certificate request - only set NotAfter if CertLifetime is configured
215+
csrRequest := certificate.ObtainForCSRRequest{
216+
CSR: req.CSR,
217+
Bundle: true,
218+
}
219+
if cfg.CertLifetime > 0 {
220+
csrRequest.NotAfter = time.Now().Add(time.Duration(cfg.CertLifetime) * 24 * time.Hour)
221+
slog.Debug("using configured certificate lifetime", "days", cfg.CertLifetime)
222+
}
223+
224+
cert, err := c.client.Certificate.ObtainForCSR(csrRequest)
210225
if err != nil {
211226
return &certificateResult{
212227
err: fmt.Errorf("failed to obtain certificate from InCommon: %v", err),
213228
}
214229
}
215230

216-
log.Printf("Successfully obtained certificate from InCommon for domains: %v", req.CSR.DNSNames)
231+
slog.Info("obtained certificate from external CA", "domains", req.CSR.DNSNames)
217232

218233
leaf, intermediates, err := c.splitCertificateBundle(cert.Certificate)
219234
if err != nil {
@@ -271,6 +286,38 @@ func (c *ExternalCAS) RenewCertificate(req *apiv1.RenewCertificateRequest) (*api
271286
}
272287

273288
func (c *ExternalCAS) RevokeCertificate(req *apiv1.RevokeCertificateRequest) (*apiv1.RevokeCertificateResponse, error) {
274-
c.client.Certificate.Revoke(req.Certificate.Raw)
275-
return nil, apiv1.NotImplementedError{}
289+
if req == nil || req.Certificate == nil {
290+
return nil, errors.New("certificate cannot be nil")
291+
}
292+
293+
if err := c.initClient(); err != nil {
294+
return nil, fmt.Errorf("failed to initialize ACME client: %w", err)
295+
}
296+
297+
// Convert DER-encoded certificate to PEM (lego expects PEM)
298+
pemBytes := pem.EncodeToMemory(&pem.Block{
299+
Type: "CERTIFICATE",
300+
Bytes: req.Certificate.Raw,
301+
})
302+
303+
slog.Info("revoking certificate",
304+
"serial", req.Certificate.SerialNumber.String(),
305+
"subject", req.Certificate.Subject.CommonName,
306+
)
307+
308+
if err := c.client.Certificate.Revoke(pemBytes); err != nil {
309+
slog.Error("failed to revoke certificate",
310+
"serial", req.Certificate.SerialNumber.String(),
311+
"error", err,
312+
)
313+
return nil, fmt.Errorf("failed to revoke certificate: %w", err)
314+
}
315+
316+
slog.Info("certificate revoked successfully",
317+
"serial", req.Certificate.SerialNumber.String(),
318+
)
319+
320+
return &apiv1.RevokeCertificateResponse{
321+
Certificate: req.Certificate,
322+
}, nil
276323
}

‎externalcas/external_test.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,48 @@ func TestRenewCertificate(t *testing.T) {
142142
}
143143

144144
func TestRevokeCertificate(t *testing.T) {
145+
ctx := context.Background()
146+
opts := apiv1.Options{
147+
Type: "externalcas",
148+
Config: []byte("{}"),
149+
}
150+
151+
extcas, err := New(ctx, opts)
152+
if err != nil {
153+
t.Fatal(err)
154+
}
155+
156+
// Table-driven tests for validation logic (no network calls)
157+
tests := []struct {
158+
name string
159+
req *apiv1.RevokeCertificateRequest
160+
wantErr string
161+
}{
162+
{
163+
name: "nil request returns error",
164+
req: nil,
165+
wantErr: "certificate cannot be nil",
166+
},
167+
{
168+
name: "nil certificate returns error",
169+
req: &apiv1.RevokeCertificateRequest{Certificate: nil},
170+
wantErr: "certificate cannot be nil",
171+
},
172+
}
173+
174+
for _, tt := range tests {
175+
t.Run(tt.name, func(t *testing.T) {
176+
_, err := extcas.RevokeCertificate(tt.req)
177+
178+
if err == nil {
179+
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
180+
}
181+
182+
if !strings.Contains(err.Error(), tt.wantErr) {
183+
t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error())
184+
}
185+
})
186+
}
145187
}
146188

147189
// Helper function that generates a self-signed test certificate in PEM format.

0 commit comments

Comments
 (0)