**feat(webhook):** add WEBHOOK_SECRET for token authentication support
All checks were successful
CI / build-test (push) Successful in 1m28s
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:
@@ -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)
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -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`.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- WEBHOOK_SECRET=${WEBHOOK_SECRET:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package diunwebhook
|
package diunwebhook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
@@ -45,10 +46,15 @@ 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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user