Skip to content

Commit 20f34f1

Browse files
authored
Merge pull request #3 from LumeWeb/refactor/env-only-config-debug-logging
refactor: env-only config, remove Caddyfile directive, add debug logging
2 parents d0ea2ea + 4f3cd6b commit 20f34f1

10 files changed

Lines changed: 122 additions & 196 deletions

File tree

README.md

Lines changed: 19 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -5,103 +5,50 @@
55
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
66
[![Tests](https://img.shields.io/github/actions/workflow/status/LumeWeb/caddy-plugin-cert-webhook/test.yml?branch=main&label=Tests)](https://github.com/LumeWeb/caddy-plugin-cert-webhook/actions)
77

8-
A Caddy v2 plugin that hooks into certificate lifecycle events and sends webhooks to a central portal service for real-time SSL certificate status tracking.
8+
A Caddy v2 app that hooks into certificate lifecycle events and reports SSL status to a portal service.
99

1010
## Features
1111

12-
- Subscribes to Caddy TLS certificate events (`cert_obtained`, `cert_renewed`, `cert_expired`)
13-
- Sends webhooks to portal endpoint with `X-Gateway-Secret` authentication
14-
- Maps Caddy events to portal SSL statuses (`pending`, `issuing`, `ready`, `failed`)
15-
- Async webhook delivery with exponential backoff retry for transient failures
16-
- Non-blocking delivery to avoid disrupting certificate operations
12+
- Subscribes to Caddy TLS events (`cert_obtained`, `cert_renewed`, `cert_expired`)
13+
- Reports status to portal via SDK (`ready` or `failed`)
14+
- Async delivery with concurrency limiting (100 concurrent)
15+
- Debug logging via Caddy's `{ debug }` global option
1716

1817
## Installation
1918

20-
Build Caddy with this plugin using [xcaddy](https://github.com/caddyserver/xcaddy):
21-
2219
```bash
2320
xcaddy build --with github.com/LumeWeb/caddy-plugin-cert-webhook
2421
```
2522

26-
## Quick Start
27-
28-
```caddyfile
29-
{
30-
order cert_webhook before file_server
31-
}
32-
33-
example.com {
34-
tls {
35-
cert_webhook {
36-
portal_url https://portal.example.com
37-
}
38-
}
39-
}
40-
```
41-
42-
Set environment variables:
43-
44-
```bash
45-
export PORTAL_URL=https://portal.example.com
46-
export GATEWAY_SECRET=your-secure-secret-key
47-
48-
caddy run --config Caddyfile
49-
```
50-
5123
## Configuration
5224

53-
### Caddyfile
25+
Env vars only — no Caddyfile directives, no JSON fields:
5426

55-
```caddyfile
56-
tls {
57-
cert_webhook {
58-
portal_url https://portal.example.com
59-
timeout 30s
60-
retry_count 5
61-
}
62-
}
63-
```
27+
| Variable | Required | Description |
28+
|----------|----------|-------------|
29+
| `PORTAL_URL` | Yes | Base URL of the portal service |
30+
| `GATEWAY_SECRET` | Yes | Shared secret for authentication |
6431

65-
### JSON
32+
Enable the app in your Caddy JSON config:
6633

6734
```json
6835
{
69-
"http.handlers": {
70-
"cert_webhook": {
71-
"portal_url": "https://portal.example.com",
72-
"timeout": "30s",
73-
"retry_count": 5
74-
}
36+
"apps": {
37+
"cert_webhook": {}
7538
}
7639
}
7740
```
7841

79-
### Environment Variables
80-
81-
| Variable | Required | Description |
82-
|----------|----------|-------------|
83-
| `PORTAL_URL` | Yes | Base URL of the portal service |
84-
| `GATEWAY_SECRET` | Yes | Shared secret for authentication |
85-
86-
## Webhook Format
87-
88-
The plugin sends POST requests to `/internal/websites/:domain/ssl-status`:
89-
90-
**Headers:**
91-
```
92-
Content-Type: application/json
93-
X-Gateway-Secret: <configured_secret>
94-
```
42+
### Debug Logging
9543

96-
**Body:**
97-
```json
44+
```caddyfile
9845
{
99-
"status": "ready",
100-
"error": "",
101-
"timestamp": "2026-02-26T01:00:00Z"
46+
debug
10247
}
10348
```
10449

50+
Enables debug output for event data, webhook delivery, and config resolution.
51+
10552
## Status Mapping
10653

10754
| Caddy Event | Portal Status |
@@ -112,34 +59,12 @@ X-Gateway-Secret: <configured_secret>
11259

11360
## Error Handling
11461

115-
- **Transient errors** (5xx, network timeouts): Retried with exponential backoff (5 attempts max)
116-
- **Permanent errors** (4xx): Logged but not retried
117-
- Webhook delivery failures do not block certificate operations
62+
- Webhook delivery failures are logged but do not block certificate operations
11863

11964
## Development
12065

12166
```bash
122-
# Build Caddy with plugin
123-
xcaddy build --with github.com/LumeWeb/caddy-plugin-cert-webhook
124-
125-
# Run tests
12667
go test ./...
127-
128-
# Run tests with coverage
129-
go test -coverprofile=coverage.out ./...
130-
go tool cover -html=coverage.out -o coverage.html
131-
132-
# Run linters
13368
go vet ./...
134-
135-
# Format code
13669
go fmt ./...
137-
138-
# Download dependencies
139-
go mod download
140-
go mod tidy
14170
```
142-
143-
## License
144-
145-
MIT License - see [LICENSE](LICENSE) for details.

client_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func TestWebhookDelivery_SuccessfulDelivery(t *testing.T) {
2929
},
3030
).Return(nil)
3131

32-
delivery.deliverAsync(context.Background(), "example.com", SSLStatusReady, "", ts)
32+
delivery.deliverAsync("example.com", SSLStatusReady, "", ts)
3333
delivery.Wait()
3434
}
3535

@@ -51,7 +51,7 @@ func TestWebhookDelivery_ErrorStatus(t *testing.T) {
5151
},
5252
).Return(nil)
5353

54-
delivery.deliverAsync(context.Background(), "example.com", SSLStatusFailed, errorMsg, ts)
54+
delivery.deliverAsync("example.com", SSLStatusFailed, errorMsg, ts)
5555
delivery.Wait()
5656
}
5757

@@ -71,7 +71,7 @@ func TestWebhookDelivery_FailedDelivery(t *testing.T) {
7171
},
7272
).Return(fmt.Errorf("internal server error"))
7373

74-
delivery.deliverAsync(context.Background(), "example.com", SSLStatusReady, "", ts)
74+
delivery.deliverAsync("example.com", SSLStatusReady, "", ts)
7575
delivery.Wait()
7676

7777
failureLogs := 0
@@ -101,7 +101,7 @@ func TestWebhookDelivery_MultipleConcurrentDeliveries(t *testing.T) {
101101
Timestamp: &ts,
102102
},
103103
).Return(nil)
104-
delivery.deliverAsync(context.Background(), domain, SSLStatusReady, "", ts)
104+
delivery.deliverAsync(domain, SSLStatusReady, "", ts)
105105
}
106106

107107
delivery.Wait()

config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ const (
1212
)
1313

1414
type Config struct {
15-
PortalURL string `json:"portal_url,omitempty"`
16-
GatewaySecret string `json:"-"`
15+
PortalURL string
16+
GatewaySecret string
1717
}
1818

1919
func (c *Config) Provision() {

delivery.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@ const (
2828
LogMsgWebhookDeliveryFailed = "webhook delivery failed"
2929
)
3030

31-
func (d *WebhookDelivery) deliverAsync(ctx context.Context, domain string, status SSLStatus, errorMsg, timestamp string) {
32-
d.wg.Add(1)
33-
go func() {
34-
defer d.wg.Done()
31+
func (d *WebhookDelivery) deliverAsync(domain string, status SSLStatus, errorMsg, timestamp string) {
32+
d.wg.Go(func() {
3533
d.sem <- struct{}{}
3634
defer func() { <-d.sem }()
3735

36+
d.logger.Debug("delivering webhook",
37+
zap.String("domain", domain),
38+
zap.String("status", string(status)),
39+
zap.String("timestamp", timestamp))
40+
3841
bgCtx := context.Background()
3942

4043
req := ipfs.SSLStatusUpdateRequest{
@@ -58,7 +61,7 @@ func (d *WebhookDelivery) deliverAsync(ctx context.Context, domain string, statu
5861
zap.String("status", string(status)),
5962
zap.String("timestamp", timestamp))
6063
}
61-
}()
64+
})
6265
}
6366

6467
func (d *WebhookDelivery) Wait() {

events.go

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ type EventData struct {
4848
Error string `json:"error,omitempty"`
4949

5050
// Raw is the raw event data for debugging
51-
Raw map[string]interface{} `json:"-"`
51+
Raw map[string]any `json:"-"`
5252

5353
// EventType is the type of event (cert_obtained, cert_renewed, cert_expired)
5454
EventType string `json:"event_type"`
@@ -57,7 +57,7 @@ type EventData struct {
5757
// Event handlers for certificate lifecycle events
5858

5959
// subscribeToEvents registers handlers for certificate events
60-
func (h *WebhookHandler) subscribeToEvents(ctx caddy.Context) error {
60+
func (h *CertWebhookApp) subscribeToEvents(ctx caddy.Context) error {
6161
// Get the events app from context
6262
eventsAppIface, err := ctx.App("events")
6363
if err != nil {
@@ -80,13 +80,11 @@ func (h *WebhookHandler) subscribeToEvents(ctx caddy.Context) error {
8080
}
8181
h.logger.Info(LogMsgSubscribedToCertObtained)
8282

83-
// Subscribe to cert_renewed event
8483
if err := eventsApp.On(EventCertRenewed, h); err != nil {
8584
return fmt.Errorf("failed to subscribe to cert_renewed event: %w", err)
8685
}
8786
h.logger.Info(LogMsgSubscribedToCertRenewed)
8887

89-
// Subscribe to cert_expired event
9088
if err := eventsApp.On(EventCertExpired, h); err != nil {
9189
return fmt.Errorf("failed to subscribe to cert_expired event: %w", err)
9290
}
@@ -96,20 +94,22 @@ func (h *WebhookHandler) subscribeToEvents(ctx caddy.Context) error {
9694
}
9795

9896
// Handle implements caddyevents.Handler interface to process certificate events
99-
func (h *WebhookHandler) Handle(ctx context.Context, data caddy.Event) error {
97+
func (h *CertWebhookApp) Handle(ctx context.Context, data caddy.Event) error {
10098
eventName := data.Name()
10199

102100
switch eventName {
103101
case EventCertObtained, EventCertRenewed, EventCertExpired:
104-
return h.handleCertEvent(ctx, eventName, data)
102+
return h.handleCertEvent(eventName, data)
105103
default:
106104
h.logger.Warn(LogMsgUnknownEventType, zap.String("event", eventName))
107105
return nil
108106
}
109107
}
110108

111109
// handleCertEvent processes certificate lifecycle events (obtained, renewed, expired)
112-
func (h *WebhookHandler) handleCertEvent(ctx context.Context, eventType string, data caddy.Event) error {
110+
func (h *CertWebhookApp) handleCertEvent(eventType string, data caddy.Event) error {
111+
h.logger.Debug("handling cert event", zap.String("event_type", eventType))
112+
113113
eventData, err := h.extractEventData(eventType, data)
114114
if err != nil {
115115
h.logger.Error(LogMsgFailedToExtractEventData,
@@ -132,14 +132,14 @@ func (h *WebhookHandler) handleCertEvent(ctx context.Context, eventType string,
132132
zap.String("status", string(status)),
133133
zap.String("timestamp", eventData.Timestamp))
134134

135-
return h.sendWebhook(ctx, eventData.Domain, status, eventData.Error, eventData.Timestamp)
135+
return h.sendWebhook(eventData.Domain, status, eventData.Error, eventData.Timestamp)
136136
}
137137

138138
// extractEventData extracts domain, timestamp, and error information from Caddy event data
139-
func (h *WebhookHandler) extractEventData(eventType string, event caddy.Event) (*EventData, error) {
139+
func (h *CertWebhookApp) extractEventData(eventType string, event caddy.Event) (*EventData, error) {
140140
data := &EventData{
141141
EventType: eventType,
142-
Raw: make(map[string]interface{}),
142+
Raw: make(map[string]any),
143143
}
144144

145145
if len(event.Data) == 0 {
@@ -150,7 +150,8 @@ func (h *WebhookHandler) extractEventData(eventType string, event caddy.Event) (
150150
return nil, fmt.Errorf("failed to unmarshal event data: %w", err)
151151
}
152152

153-
// Extract timestamp from event data, default to current time
153+
h.logger.Debug("raw event data decoded", zap.Any("data", data.Raw))
154+
154155
if ts, ok := data.Raw["ts"].(float64); ok {
155156
sec := int64(ts)
156157
nsec := int64((ts - float64(sec)) * 1e9)
@@ -161,13 +162,14 @@ func (h *WebhookHandler) extractEventData(eventType string, event caddy.Event) (
161162

162163
if domain, ok := data.Raw["domain"].(string); ok {
163164
data.Domain = domain
164-
} else if san, ok := data.Raw["sans"].([]interface{}); ok && len(san) > 0 {
165+
} else if san, ok := data.Raw["sans"].([]any); ok && len(san) > 0 {
165166
if d, ok := san[0].(string); ok {
166167
data.Domain = d
167168
}
168169
}
169170

170171
if data.Domain == "" {
172+
h.logger.Debug("no domain found in event data")
171173
return nil, fmt.Errorf("no domain found in event data")
172174
}
173175

@@ -179,7 +181,7 @@ func (h *WebhookHandler) extractEventData(eventType string, event caddy.Event) (
179181
}
180182

181183
// decodeJSON decodes JSON data from map[string]any to target
182-
func decodeJSON(data map[string]any, target interface{}) error {
184+
func decodeJSON(data map[string]any, target any) error {
183185
jsonBytes, err := json.Marshal(data)
184186
if err != nil {
185187
return err

0 commit comments

Comments
 (0)