diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bd16efe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /terdut ./cmd/terdut + +FROM scratch +COPY --from=builder /terdut /terdut +EXPOSE 8080 +ENTRYPOINT ["/terdut"] diff --git a/README.md b/README.md index 650c8f9..74b1973 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,149 @@ -# Terminal Duty -Terminal Duty(termdut) is a server that helps teams manage alerts, oncall schedules etc. -- The app will integrate with alertmanager(prometheus alertmanager), as a receiver of alertmanager webhooks without the need for adapters. -- Incoming alerts will be logged to a database(postgres or sqlite). -- Alerts can be marked as "acknowledged", when someone is already working on it. -- Comments can be added to alerts, such as solutions, tips or actions. -- Statistics can be produced for the alerts in the database, such as most common alert, most busy times of the day/week etc. -- The server exposes a rest-api, where users, schedules, alerts etc can be managed. -- Alerts will be received from alertmanager with no authentication initially. -- The exposed api however(besides receiving alert from alertmanager) needs authentication. -- Authentication is managed on a user basis, each user has his or her own credentials, these are stored hashed in the database. -- Each user can be assigned to time slots in the oncall-schedule. -- The smallest unit of time managed in the schedule is a day. This may change later. -- The server is written in golang +# Terminal Duty (terdut-server) + +On-call alert management server for teams using Prometheus Alertmanager. + +- Receives Alertmanager webhooks directly — no adapter needed +- Stores and queries alerts (acknowledge, comment) +- On-call schedule management (user-to-day assignments) +- Alert statistics (by status, by hour, by day) +- REST API with per-user API key authentication +- Single binary, SQLite storage — trivial to self-host + +--- + +## Quick start + +**Prerequisites:** Go 1.21+ + +```bash +git clone https://github.com/yeniklas/terdut-server +cd terdut-server +go run ./cmd/terdut +``` + +The server starts on `:8080` with a `terdut.db` file in the working directory. + +### Create the first user + +```bash +curl -X POST http://localhost:8080/api/bootstrap \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "email": "admin@example.com"}' +``` + +Save the `api_key.key` value from the response — it is shown **once only**. + +Use it as a bearer token for all subsequent requests: + +```bash +export KEY= +curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users +``` + +### Docker + +```bash +docker build -t terdut-server . +docker run -p 8080:8080 -v $(pwd)/data:/data \ + -e TERDUT_DB_PATH=/data/terdut.db \ + terdut-server +``` + +--- + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `TERDUT_ADDR` | `:8080` | TCP address to listen on | +| `TERDUT_DB_PATH` | `terdut.db` | Path to the SQLite database file | + +--- + +## Alertmanager configuration + +Add terdut-server as a webhook receiver in your `alertmanager.yml`: + +```yaml +receivers: + - name: terdut + webhook_configs: + - url: http://terdut-server:8080/api/alertmanager/webhook + send_resolved: true + +route: + receiver: terdut +``` + +The webhook endpoint requires no authentication. + +--- + +## API reference + +### Authentication + +All endpoints except `/api/bootstrap` and `/api/alertmanager/webhook` require: + +``` +Authorization: Bearer +``` + +### Users + +| Method | Path | Description | +|---|---|---| +| `POST` | `/api/bootstrap` | Create first user + API key (only works on empty DB) | +| `GET` | `/api/users` | List users | +| `POST` | `/api/users` | Create user `{"username","email"}` | +| `DELETE` | `/api/users/{id}` | Delete user (cascades to keys) | +| `POST` | `/api/users/{id}/api-keys` | Issue API key `{"name"}` — key shown once | +| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | Revoke API key | + +### Alert ingestion + +| Method | Path | Description | +|---|---|---| +| `POST` | `/api/alertmanager/webhook` | Alertmanager v4 webhook receiver (no auth) | + +### Alerts + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/alerts` | List alerts. Filters: `?status=firing\|resolved`, `?name=`, `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD`, `?limit=` (default 50, max 500) | +| `GET` | `/api/alerts/{id}` | Get single alert | +| `POST` | `/api/alerts/{id}/acknowledge` | Acknowledge alert (stamps authed user + time) | +| `DELETE` | `/api/alerts/{id}/acknowledge` | Clear acknowledgement | +| `GET` | `/api/alerts/{id}/comments` | List comments (chronological) | +| `POST` | `/api/alerts/{id}/comments` | Add comment `{"content"}` | +| `DELETE` | `/api/alerts/{id}/comments/{commentID}` | Delete own comment | + +### On-call schedule + +| Method | Path | Description | +|---|---|---| +| `POST` | `/api/schedule` | Assign user to dates `{"user_id", "dates":["YYYY-MM-DD",...]}` — all-or-nothing | +| `GET` | `/api/schedule` | List entries. Filters: `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD` | +| `GET` | `/api/schedule/current` | Today's on-call user (UTC), 404 if none | +| `DELETE` | `/api/schedule/{id}` | Remove schedule entry | + +### Statistics + +All stat endpoints accept optional `?from=YYYY-MM-DD` and `?to=YYYY-MM-DD` to filter by `received_at`. + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/stats/alerts` | `{total, firing, resolved}` counts | +| `GET` | `/api/stats/alerts/top` | Most frequent alert names. `?limit=` (default 10, max 100) | +| `GET` | `/api/stats/alerts/by-hour` | Count per hour-of-day (UTC), all 24 slots returned | +| `GET` | `/api/stats/alerts/by-day` | Count per day-of-week, all 7 slots with names returned | + +--- + +## Development + +```bash +go test ./... # run all tests +go build ./... # compile all packages +go run ./cmd/terdut # run locally +``` diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..e8a0d36 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,368 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/yeniklas/terdut-server/internal/api" + "github.com/yeniklas/terdut-server/internal/db" +) + +// ts wraps httptest.Server with a pre-bootstrapped API key. +type ts struct { + *httptest.Server + key string +} + +func newTS(t *testing.T) *ts { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("migrate: %v", err) + } + srv := httptest.NewServer(api.NewRouter(database)) + t.Cleanup(func() { srv.Close(); database.Close() }) + + body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"}) + resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("bootstrap returned %d", resp.StatusCode) + } + var result map[string]any + json.NewDecoder(resp.Body).Decode(&result) + key := result["api_key"].(map[string]any)["key"].(string) + + return &ts{Server: srv, key: key} +} + +// req sends an authenticated request, optionally with a JSON body. +func (s *ts) req(t *testing.T, method, path string, body any) *http.Response { + t.Helper() + var r io.Reader + if body != nil { + data, _ := json.Marshal(body) + r = bytes.NewReader(data) + } + req, _ := http.NewRequest(method, s.URL+path, r) + req.Header.Set("Authorization", "Bearer "+s.key) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +func decode(t *testing.T, resp *http.Response, v any) { + t.Helper() + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + t.Fatalf("decode response: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Auth middleware +// --------------------------------------------------------------------------- + +func TestAuthMiddleware_MissingToken(t *testing.T) { + s := newTS(t) + req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/users", nil) + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", resp.StatusCode) + } +} + +func TestAuthMiddleware_InvalidToken(t *testing.T) { + s := newTS(t) + req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/users", nil) + req.Header.Set("Authorization", "Bearer notavalidkey") + resp, _ := http.DefaultClient.Do(req) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", resp.StatusCode) + } +} + +func TestAuthMiddleware_ValidToken(t *testing.T) { + s := newTS(t) + resp := s.req(t, http.MethodGet, "/api/users", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} + +// --------------------------------------------------------------------------- +// Bootstrap idempotency +// --------------------------------------------------------------------------- + +func TestBootstrap_SecondCallForbidden(t *testing.T) { + s := newTS(t) // already bootstrapped + body, _ := json.Marshal(map[string]string{"username": "x", "email": "x@x.com"}) + resp, _ := http.Post(s.URL+"/api/bootstrap", "application/json", bytes.NewReader(body)) + if resp.StatusCode != http.StatusForbidden { + t.Errorf("expected 403, got %d", resp.StatusCode) + } +} + +// --------------------------------------------------------------------------- +// Alert upsert by fingerprint +// --------------------------------------------------------------------------- + +func postWebhook(t *testing.T, s *ts, alerts []map[string]any) { + t.Helper() + payload := map[string]any{"version": "4", "status": "firing", "alerts": alerts} + data, _ := json.Marshal(payload) + resp, err := http.Post(s.URL+"/api/alertmanager/webhook", "application/json", bytes.NewReader(data)) + if err != nil { + t.Fatalf("post webhook: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("webhook returned %d", resp.StatusCode) + } +} + +func TestAlertUpsert_SameFingerprintUpdates(t *testing.T) { + s := newTS(t) + + alert := map[string]any{ + "status": "firing", + "labels": map[string]string{"alertname": "HighCPU"}, + "annotations": map[string]string{}, + "startsAt": "2026-05-20T10:00:00Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "", + "fingerprint": "fp-upsert", + } + postWebhook(t, s, []map[string]any{alert}) + + // Same fingerprint, now resolved. + alert["status"] = "resolved" + alert["endsAt"] = "2026-05-20T11:00:00Z" + postWebhook(t, s, []map[string]any{alert}) + + resp := s.req(t, http.MethodGet, "/api/alerts", nil) + var list []map[string]any + decode(t, resp, &list) + + if len(list) != 1 { + t.Fatalf("expected 1 alert (upsert), got %d", len(list)) + } + if list[0]["status"] != "resolved" { + t.Errorf("expected status resolved, got %s", list[0]["status"]) + } + if list[0]["ends_at"] == nil { + t.Error("expected ends_at to be set after resolve") + } +} + +func TestAlertUpsert_DifferentFingerprintsStored(t *testing.T) { + s := newTS(t) + + for i := range 3 { + alert := map[string]any{ + "status": "firing", + "labels": map[string]string{"alertname": fmt.Sprintf("Alert%d", i)}, + "annotations": map[string]string{}, + "startsAt": "2026-05-20T10:00:00Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "", + "fingerprint": fmt.Sprintf("fp-%d", i), + } + postWebhook(t, s, []map[string]any{alert}) + } + + resp := s.req(t, http.MethodGet, "/api/alerts", nil) + var list []map[string]any + decode(t, resp, &list) + if len(list) != 3 { + t.Errorf("expected 3 alerts, got %d", len(list)) + } +} + +// --------------------------------------------------------------------------- +// Alert acknowledge +// --------------------------------------------------------------------------- + +func TestAcknowledge(t *testing.T) { + s := newTS(t) + postWebhook(t, s, []map[string]any{{ + "status": "firing", "labels": map[string]string{"alertname": "X"}, + "annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z", + "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", "fingerprint": "fp-ack", + }}) + + resp := s.req(t, http.MethodPost, "/api/alerts/1/acknowledge", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("acknowledge returned %d", resp.StatusCode) + } + var alert map[string]any + decode(t, resp, &alert) + if alert["acknowledged_by"] == nil { + t.Error("expected acknowledged_by to be set") + } + + // Clear it. + resp = s.req(t, http.MethodDelete, "/api/alerts/1/acknowledge", nil) + if resp.StatusCode != http.StatusNoContent { + t.Errorf("unacknowledge returned %d", resp.StatusCode) + } + + resp = s.req(t, http.MethodGet, "/api/alerts/1", nil) + var alert2 map[string]any + decode(t, resp, &alert2) + if alert2["acknowledged_by"] != nil { + t.Error("expected acknowledged_by to be cleared") + } +} + +// --------------------------------------------------------------------------- +// Comments — own-only deletion +// --------------------------------------------------------------------------- + +func TestComment_DeleteOwnOnly(t *testing.T) { + s := newTS(t) + postWebhook(t, s, []map[string]any{{ + "status": "firing", "labels": map[string]string{"alertname": "Y"}, + "annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z", + "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", "fingerprint": "fp-comment", + }}) + + // Create a second user and their own key. + s.req(t, http.MethodPost, "/api/users", + map[string]string{"username": "alice", "email": "alice@test.com"}) + keyResp := s.req(t, http.MethodPost, "/api/users/2/api-keys", + map[string]string{"name": "alice-key"}) + var keyData map[string]any + decode(t, keyResp, &keyData) + aliceKey := keyData["key"].(string) + + // Admin posts a comment. + s.req(t, http.MethodPost, "/api/alerts/1/comments", + map[string]string{"content": "admin note"}) + + // Alice tries to delete admin's comment (should 404). + req, _ := http.NewRequest(http.MethodDelete, s.URL+"/api/alerts/1/comments/1", nil) + req.Header.Set("Authorization", "Bearer "+aliceKey) + resp, _ := http.DefaultClient.Do(req) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 when deleting another user's comment, got %d", resp.StatusCode) + } + + // Admin deletes own comment (should 204). + resp = s.req(t, http.MethodDelete, "/api/alerts/1/comments/1", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("expected 204 when deleting own comment, got %d", resp.StatusCode) + } +} + +// --------------------------------------------------------------------------- +// Schedule conflict +// --------------------------------------------------------------------------- + +func TestSchedule_ConflictOnSameDate(t *testing.T) { + s := newTS(t) + + first := s.req(t, http.MethodPost, "/api/schedule", + map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}) + if first.StatusCode != http.StatusCreated { + t.Fatalf("first assignment returned %d", first.StatusCode) + } + first.Body.Close() + + second := s.req(t, http.MethodPost, "/api/schedule", + map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}}) + if second.StatusCode != http.StatusConflict { + t.Errorf("expected 409 on duplicate date, got %d", second.StatusCode) + } + second.Body.Close() +} + +func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) { + s := newTS(t) + + // Claim 2026-06-10 first. + s.req(t, http.MethodPost, "/api/schedule", + map[string]any{"user_id": 1, "dates": []string{"2026-06-10"}}).Body.Close() + + // Try to assign two dates in one request where the second conflicts. + resp := s.req(t, http.MethodPost, "/api/schedule", + map[string]any{"user_id": 1, "dates": []string{"2026-06-09", "2026-06-10"}}) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("expected 409, got %d", resp.StatusCode) + } + resp.Body.Close() + + // 2026-06-09 must NOT have been committed (transaction rolled back). + listResp := s.req(t, http.MethodGet, "/api/schedule?from=2026-06-09&to=2026-06-09", nil) + var entries []any + decode(t, listResp, &entries) + if len(entries) != 0 { + t.Errorf("expected rollback to leave 2026-06-09 unassigned, got %d entries", len(entries)) + } +} + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +func TestStats_Totals(t *testing.T) { + s := newTS(t) + + alerts := []map[string]any{ + {"status": "firing", "labels": map[string]string{"alertname": "A"}, "annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z", "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", "fingerprint": "s1"}, + {"status": "resolved", "labels": map[string]string{"alertname": "B"}, "annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z", "endsAt": "2026-05-20T11:00:00Z", "generatorURL": "", "fingerprint": "s2"}, + } + postWebhook(t, s, alerts) + + resp := s.req(t, http.MethodGet, "/api/stats/alerts", nil) + var stats map[string]any + decode(t, resp, &stats) + + if int(stats["total"].(float64)) != 2 { + t.Errorf("expected total=2, got %v", stats["total"]) + } + if int(stats["firing"].(float64)) != 1 { + t.Errorf("expected firing=1, got %v", stats["firing"]) + } + if int(stats["resolved"].(float64)) != 1 { + t.Errorf("expected resolved=1, got %v", stats["resolved"]) + } +} + +func TestStats_ByHourReturnsTwentyFourSlots(t *testing.T) { + s := newTS(t) + resp := s.req(t, http.MethodGet, "/api/stats/alerts/by-hour", nil) + var slots []any + decode(t, resp, &slots) + if len(slots) != 24 { + t.Errorf("expected 24 hour slots, got %d", len(slots)) + } +} + +func TestStats_ByDayReturnsSevenSlots(t *testing.T) { + s := newTS(t) + resp := s.req(t, http.MethodGet, "/api/stats/alerts/by-day", nil) + var slots []any + decode(t, resp, &slots) + if len(slots) != 7 { + t.Errorf("expected 7 day slots, got %d", len(slots)) + } +}