**feat(webhook):** add WEBHOOK_SECRET for token authentication support
All checks were successful
CI / build-test (push) Successful in 1m28s

- Protect `/webhook` endpoint using the `Authorization` header
- Update `README.md` with setup instructions and examples for authentication
- Warn when `WEBHOOK_SECRET` is not configured
- Add tests for valid, missing, and invalid token scenarios
- Update `docker-compose.yml` to support `WEBHOOK_SECRET` configuration
This commit is contained in:
2026-02-27 14:58:43 +01:00
parent db9f47649d
commit c0746a7f02
7 changed files with 114 additions and 3 deletions

View File

@@ -54,6 +54,10 @@ The app is a Go HTTP server that receives [DIUN](https://crazymax.dev/diun/) web
- `PUT /api/tag-assignments` — assign an image to a tag - `PUT /api/tag-assignments` — assign an image to a tag
- `DELETE /api/tag-assignments` — unassign an image from its tag - `DELETE /api/tag-assignments` — unassign an image from its tag
**Environment variables:**
- `PORT` — listen port (default `8080`)
- `WEBHOOK_SECRET` — when set, every `POST /webhook` must include a matching `Authorization` header; when unset, the webhook is open (a warning is logged at startup)
**Key data flow:** **Key data flow:**
1. DIUN POSTs JSON to `/webhook``WebhookHandler` decodes into `DiunEvent` → upserted into `updates` table (latest event per image wins, resets acknowledged state) 1. DIUN POSTs JSON to `/webhook``WebhookHandler` decodes into `DiunEvent` → upserted into `updates` table (latest event per image wins, resets acknowledged state)
2. React SPA polls `GET /api/updates` every 5 s → `UpdatesHandler` returns map of `UpdateEntry` (includes event, received time, acknowledged flag, and optional tag) 2. React SPA polls `GET /api/updates` every 5 s → `UpdatesHandler` returns map of `UpdateEntry` (includes event, received time, acknowledged flag, and optional tag)

View File

@@ -34,6 +34,18 @@ docker compose up -d
# open http://localhost:8080 # open http://localhost:8080
``` ```
## Webhook authentication
Set `WEBHOOK_SECRET` to protect the webhook endpoint with token authentication. When set, every `POST /webhook` must include a matching `Authorization` header. When unset, the webhook is open (a warning is logged at startup).
```bash
# Run with authentication
WEBHOOK_SECRET=your-secret-token-here go run ./cmd/diunwebhook/
# Or via Docker Compose (.env file or inline)
WEBHOOK_SECRET=your-secret-token-here docker compose up -d
```
## DIUN configuration example ## DIUN configuration example
Configure DIUN to send webhooks to this app. Example (YAML): Configure DIUN to send webhooks to this app. Example (YAML):
@@ -42,8 +54,14 @@ notif:
webhook: webhook:
enable: true enable: true
endpoint: http://your-host-or-ip:8080/webhook endpoint: http://your-host-or-ip:8080/webhook
headers:
authorization: "your-secret-token-here"
``` ```
Or via env: `DIUN_NOTIF_WEBHOOK_HEADERS_AUTHORIZATION=your-secret-token-here`
The `authorization` header value must match `WEBHOOK_SECRET` exactly.
Expected JSON payload (simplified): Expected JSON payload (simplified):
```json ```json
{ {
@@ -110,7 +128,7 @@ Aim for 80-90% coverage. Coverage below 80% will emit a warning in CI but will n
## Production notes ## Production notes
- Behind a reverse proxy, ensure the app is reachable at `/webhook` from DIUN. - Behind a reverse proxy, ensure the app is reachable at `/webhook` from DIUN.
- Data is persisted to `diun.db` in the working directory. Mount a volume to preserve data across container recreations. - Data is persisted to `diun.db` in the working directory. Mount a volume to preserve data across container recreations.
- Consider adding auth, rate limiting, or a secret/token on the webhook endpoint if exposed publicly. - Set `WEBHOOK_SECRET` to protect the webhook endpoint if exposed publicly.
## License ## License
MIT — see `LICENSE`. MIT — see `LICENSE`.

View File

@@ -18,6 +18,14 @@ func main() {
log.Fatalf("InitDB: %v", err) log.Fatalf("InitDB: %v", err)
} }
secret := os.Getenv("WEBHOOK_SECRET")
if secret == "" {
log.Println("WARNING: WEBHOOK_SECRET not set — webhook endpoint is unprotected")
} else {
diun.SetWebhookSecret(secret)
log.Println("Webhook endpoint protected with token authentication")
}
port := os.Getenv("PORT") port := os.Getenv("PORT")
if port == "" { if port == "" {
port = "8080" port = "8080"

View File

@@ -4,4 +4,6 @@ services:
build: . build: .
ports: ports:
- "8080:8080" - "8080:8080"
environment:
- WEBHOOK_SECRET=${WEBHOOK_SECRET:-}
restart: unless-stopped restart: unless-stopped

View File

@@ -1,6 +1,7 @@
package diunwebhook package diunwebhook
import ( import (
"crypto/subtle"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"log" "log"
@@ -47,8 +48,13 @@ type UpdateEntry struct {
var ( var (
mu sync.Mutex mu sync.Mutex
db *sql.DB db *sql.DB
webhookSecret string
) )
func SetWebhookSecret(secret string) {
webhookSecret = secret
}
func InitDB(path string) error { func InitDB(path string) error {
var err error var err error
db, err = sql.Open("sqlite", path) db, err = sql.Open("sqlite", path)
@@ -155,6 +161,14 @@ func GetUpdates() (map[string]UpdateEntry, error) {
} }
func WebhookHandler(w http.ResponseWriter, r *http.Request) { func WebhookHandler(w http.ResponseWriter, r *http.Request) {
if webhookSecret != "" {
auth := r.Header.Get("Authorization")
if subtle.ConstantTimeCompare([]byte(auth), []byte(webhookSecret)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return

View File

@@ -76,6 +76,67 @@ func TestWebhookHandler(t *testing.T) {
} }
} }
func TestWebhookHandler_Unauthorized(t *testing.T) {
diun.UpdatesReset()
diun.SetWebhookSecret("my-secret")
defer diun.ResetWebhookSecret()
event := diun.DiunEvent{Image: "nginx:latest"}
body, _ := json.Marshal(event)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
rec := httptest.NewRecorder()
diun.WebhookHandler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
func TestWebhookHandler_WrongToken(t *testing.T) {
diun.UpdatesReset()
diun.SetWebhookSecret("my-secret")
defer diun.ResetWebhookSecret()
event := diun.DiunEvent{Image: "nginx:latest"}
body, _ := json.Marshal(event)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("Authorization", "wrong-token")
rec := httptest.NewRecorder()
diun.WebhookHandler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
func TestWebhookHandler_ValidToken(t *testing.T) {
diun.UpdatesReset()
diun.SetWebhookSecret("my-secret")
defer diun.ResetWebhookSecret()
event := diun.DiunEvent{Image: "nginx:latest"}
body, _ := json.Marshal(event)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
req.Header.Set("Authorization", "my-secret")
rec := httptest.NewRecorder()
diun.WebhookHandler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
}
func TestWebhookHandler_NoSecretConfigured(t *testing.T) {
diun.UpdatesReset()
diun.ResetWebhookSecret()
event := diun.DiunEvent{Image: "nginx:latest"}
body, _ := json.Marshal(event)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
rec := httptest.NewRecorder()
diun.WebhookHandler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200 (no secret configured), got %d", rec.Code)
}
}
func TestWebhookHandler_BadRequest(t *testing.T) { func TestWebhookHandler_BadRequest(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte("not-json"))) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte("not-json")))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View File

@@ -13,3 +13,7 @@ func ResetTags() {
db.Exec(`DELETE FROM tag_assignments`) db.Exec(`DELETE FROM tag_assignments`)
db.Exec(`DELETE FROM tags`) db.Exec(`DELETE FROM tags`)
} }
func ResetWebhookSecret() {
SetWebhookSecret("")
}