diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0ba3887..eb69080 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 - `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:** 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) diff --git a/README.md b/README.md index 357aae2..2eb5046 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,18 @@ docker compose up -d # 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 Configure DIUN to send webhooks to this app. Example (YAML): @@ -42,8 +54,14 @@ notif: webhook: enable: true 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): ```json { @@ -110,7 +128,7 @@ Aim for 80-90% coverage. Coverage below 80% will emit a warning in CI but will n ## Production notes - 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. -- 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 MIT — see `LICENSE`. diff --git a/cmd/diunwebhook/main.go b/cmd/diunwebhook/main.go index e42e3f2..156d974 100644 --- a/cmd/diunwebhook/main.go +++ b/cmd/diunwebhook/main.go @@ -18,6 +18,14 @@ func main() { 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") if port == "" { port = "8080" diff --git a/docker-compose.yml b/docker-compose.yml index 01dcf0f..fa7cef9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,4 +4,6 @@ services: build: . ports: - "8080:8080" + environment: + - WEBHOOK_SECRET=${WEBHOOK_SECRET:-} restart: unless-stopped diff --git a/pkg/diunwebhook/diunwebhook.go b/pkg/diunwebhook/diunwebhook.go index 261fb3f..5227e10 100644 --- a/pkg/diunwebhook/diunwebhook.go +++ b/pkg/diunwebhook/diunwebhook.go @@ -1,6 +1,7 @@ package diunwebhook import ( + "crypto/subtle" "database/sql" "encoding/json" "log" @@ -45,10 +46,15 @@ type UpdateEntry struct { } var ( - mu sync.Mutex - db *sql.DB + mu sync.Mutex + db *sql.DB + webhookSecret string ) +func SetWebhookSecret(secret string) { + webhookSecret = secret +} + func InitDB(path string) error { var err error db, err = sql.Open("sqlite", path) @@ -155,6 +161,14 @@ func GetUpdates() (map[string]UpdateEntry, error) { } 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 { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return diff --git a/pkg/diunwebhook/diunwebhook_test.go b/pkg/diunwebhook/diunwebhook_test.go index bd52cb3..ba635cc 100644 --- a/pkg/diunwebhook/diunwebhook_test.go +++ b/pkg/diunwebhook/diunwebhook_test.go @@ -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) { req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte("not-json"))) rec := httptest.NewRecorder() diff --git a/pkg/diunwebhook/export_test.go b/pkg/diunwebhook/export_test.go index 31b063f..5cf638a 100644 --- a/pkg/diunwebhook/export_test.go +++ b/pkg/diunwebhook/export_test.go @@ -13,3 +13,7 @@ func ResetTags() { db.Exec(`DELETE FROM tag_assignments`) db.Exec(`DELETE FROM tags`) } + +func ResetWebhookSecret() { + SetWebhookSecret("") +}