diff --git a/CHANGELOG.md b/CHANGELOG.md index a31775a..15078fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to this project will be documented in this file. +## [1.3.0] + +### Added +- Custom request headers per monitor. Set them as `Key: Value` lines from + the monitor detail page, or via the JSON API on create. +- Outbound auth per monitor. Choose Basic (`user:pass`) or Bearer; pingtower + attaches the matching `Authorization` header on every poll. +- Dashboard routes `POST /dashboard/checks/{id}/headers` and + `POST /dashboard/checks/{id}/auth` for managing headers and auth. +- New `headers`, `auth_type`, and `auth_value` fields on the check model and + on `POST /checks`. +- New `SetCheckHeaders` and `SetCheckAuth` store methods. + +### Notes +- Auth values are stored in the data file as plain text. Restrict + permissions on `data/pingtower.json` to trusted users. +- The dashboard reflects the masked state of an existing auth value rather + than the secret itself. Submitting the auth form with the value field + blank preserves the existing secret. +- Unlocks monitoring of authenticated APIs that were previously impossible + to check (anything behind an API key, OAuth bearer, or HTTP Basic). + ## [1.2.0] ### Added diff --git a/README.md b/README.md index 15de91b..e608b5d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ - Pause and resume polling per monitor - Remove monitors you no longer need - Configure expected status code and timeout +- Send custom request headers and Basic / Bearer auth on each poll +- Webhook alerts on status transitions - Run locally with Go or in Docker ## Dashboard @@ -101,6 +103,26 @@ curl -X POST http://localhost:8080/checks \ }' ``` +### Create a monitor with custom headers and a Bearer token + +```bash +curl -X POST http://localhost:8080/checks \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Authenticated API", + "url": "https://api.example.com/me", + "headers": { + "X-API-Key": "abc123", + "Accept": "application/json" + }, + "auth_type": "bearer", + "auth_value": "my-secret-token" + }' +``` + +`auth_type` accepts `none`, `basic`, or `bearer`. For `basic`, `auth_value` is +the literal `user:pass` string (pingtower base64-encodes it on each request). + ### List monitors ```bash @@ -157,10 +179,17 @@ internal/store file-backed persistence ## Roadmap -- Add webhook or email alerts -- Support request headers and auth - Improve live dashboard updates without full page refresh - Move persistence to SQLite or Postgres +- Dashboard authentication + +## Security note + +Custom auth values (Basic credentials and Bearer tokens) are stored in the +data file in plain text. Restrict file permissions on `data/pingtower.json` +to trusted users, and consider running pingtower behind a reverse proxy or +on a private network rather than exposing the dashboard publicly — there is +no authentication on the dashboard yet. ## License diff --git a/internal/httpapi/dashboard.go b/internal/httpapi/dashboard.go index 208f19c..21a50af 100644 --- a/internal/httpapi/dashboard.go +++ b/internal/httpapi/dashboard.go @@ -6,6 +6,7 @@ import ( "fmt" "html/template" "net/http" + "sort" "strconv" "strings" "time" @@ -33,6 +34,10 @@ type dashboardCheckView struct { TimeoutSeconds int Paused bool WebhookURL string + Headers map[string]string + HeadersText string + AuthType string + HasAuth bool LastStatus string LastStatusCode int LastResponseMS int64 @@ -151,6 +156,50 @@ func (s *Server) handleDashboardCheckAction(w http.ResponseWriter, r *http.Reque } http.Redirect(w, r, fmt.Sprintf("/checks/%s/view", checkID), http.StatusSeeOther) return + case "headers": + headers := parseHeadersText(r.FormValue("headers")) + if _, err := s.store.SetCheckHeaders(checkID, headers); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to update headers", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/checks/%s/view", checkID), http.StatusSeeOther) + return + case "auth": + authType := strings.TrimSpace(r.FormValue("auth_type")) + authValue := r.FormValue("auth_value") + if authType != "" && authType != "none" && authType != "basic" && authType != "bearer" { + http.Error(w, "invalid auth_type", http.StatusBadRequest) + return + } + if authType == "none" || authType == "" { + authValue = "" + } else if strings.TrimSpace(authValue) == "" { + // keep the existing secret if the user didn't supply a new one + existing, err := s.store.GetCheck(checkID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to load check", http.StatusInternalServerError) + return + } + authValue = existing.AuthValue + } + if _, err := s.store.SetCheckAuth(checkID, authType, authValue); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to update auth", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/checks/%s/view", checkID), http.StatusSeeOther) + return case "trigger": if s.triggerer == nil { http.Error(w, "trigger not available", http.StatusNotImplemented) @@ -303,6 +352,10 @@ func newDashboardCheckView(check model.Check) dashboardCheckView { TimeoutSeconds: check.TimeoutSeconds, Paused: check.Paused, WebhookURL: check.WebhookURL, + Headers: check.Headers, + HeadersText: headersToText(check.Headers), + AuthType: check.AuthType, + HasAuth: check.AuthValue != "", LastStatus: effectiveStatus(check), LastStatusCode: check.LastStatusCode, LastResponseMS: check.LastResponseMS, @@ -375,6 +428,51 @@ func withFormDefaults(values map[string]string, s *Server) map[string]string { return values } +func parseHeadersText(text string) map[string]string { + headers := map[string]string{} + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + idx := strings.Index(line, ":") + if idx <= 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + if key == "" { + continue + } + headers[key] = value + } + if len(headers) == 0 { + return nil + } + return headers +} + +func headersToText(headers map[string]string) string { + if len(headers) == 0 { + return "" + } + keys := make([]string, 0, len(headers)) + for k := range headers { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(k) + b.WriteString(": ") + b.WriteString(headers[k]) + } + return b.String() +} + func parseIntOrZero(value string) int { if strings.TrimSpace(value) == "" { return 0 diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7080125..605406c 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -32,11 +32,14 @@ func (s *Server) SetTriggerer(t Triggerer) { } type createCheckRequest struct { - Name string `json:"name"` - URL string `json:"url"` - IntervalSeconds int `json:"interval_seconds"` - TimeoutSeconds int `json:"timeout_seconds"` - ExpectedStatusCode int `json:"expected_status_code"` + Name string `json:"name"` + URL string `json:"url"` + IntervalSeconds int `json:"interval_seconds"` + TimeoutSeconds int `json:"timeout_seconds"` + ExpectedStatusCode int `json:"expected_status_code"` + Headers map[string]string `json:"headers,omitempty"` + AuthType string `json:"auth_type,omitempty"` + AuthValue string `json:"auth_value,omitempty"` } func NewServer(cfg config.Config, logger *log.Logger, dataStore store.Store) *Server { diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 1216fb7..4161066 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -394,3 +394,127 @@ func TestSetWebhookViaDashboard(t *testing.T) { t.Fatalf("WebhookURL = %q after clear, want empty", check.WebhookURL) } } + +func TestSetHeadersViaDashboard(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dataStore, err := store.NewFileStore(filepath.Join(dir, "pingtower.json")) + if err != nil { + t.Fatalf("NewFileStore() error = %v", err) + } + + created, err := dataStore.CreateCheck(model.Check{ + Name: "Headers Form Test", + URL: "https://example.com", + IntervalSeconds: 60, + TimeoutSeconds: 5, + ExpectedStatusCode: 200, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore) + + form := url.Values{"headers": []string{"X-API-Key: abc123\nAccept: application/json"}} + req := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/headers", bytes.NewBufferString(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusSeeOther { + t.Fatalf("set headers status = %d, want %d", res.Code, http.StatusSeeOther) + } + + check, err := dataStore.GetCheck(created.ID) + if err != nil { + t.Fatalf("GetCheck() error = %v", err) + } + if check.Headers["X-API-Key"] != "abc123" || check.Headers["Accept"] != "application/json" { + t.Fatalf("Headers = %v, want both keys parsed", check.Headers) + } +} + +func TestSetAuthViaDashboard(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dataStore, err := store.NewFileStore(filepath.Join(dir, "pingtower.json")) + if err != nil { + t.Fatalf("NewFileStore() error = %v", err) + } + + created, err := dataStore.CreateCheck(model.Check{ + Name: "Auth Form Test", + URL: "https://example.com", + IntervalSeconds: 60, + TimeoutSeconds: 5, + ExpectedStatusCode: 200, + }) + if err != nil { + t.Fatalf("CreateCheck() error = %v", err) + } + + server := NewServer(config.Load(), log.New(io.Discard, "", 0), dataStore) + + // set bearer token + form := url.Values{ + "auth_type": []string{"bearer"}, + "auth_value": []string{"my-token"}, + } + req := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/auth", bytes.NewBufferString(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res := httptest.NewRecorder() + server.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusSeeOther { + t.Fatalf("set auth status = %d, want %d", res.Code, http.StatusSeeOther) + } + + check, err := dataStore.GetCheck(created.ID) + if err != nil { + t.Fatalf("GetCheck() error = %v", err) + } + if check.AuthType != "bearer" || check.AuthValue != "my-token" { + t.Fatalf("Auth = (%q, %q), want bearer/my-token", check.AuthType, check.AuthValue) + } + + // submitting with auth_type set but no auth_value should preserve the existing value + keepForm := url.Values{ + "auth_type": []string{"bearer"}, + "auth_value": []string{""}, + } + keepReq := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/auth", bytes.NewBufferString(keepForm.Encode())) + keepReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + keepRes := httptest.NewRecorder() + server.Handler().ServeHTTP(keepRes, keepReq) + + check, _ = dataStore.GetCheck(created.ID) + if check.AuthValue != "my-token" { + t.Fatalf("AuthValue after empty resubmit = %q, want preserved", check.AuthValue) + } + + clearForm := url.Values{ + "auth_type": []string{"none"}, + "auth_value": []string{""}, + } + clearReq := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/auth", bytes.NewBufferString(clearForm.Encode())) + clearReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + clearRes := httptest.NewRecorder() + server.Handler().ServeHTTP(clearRes, clearReq) + + check, _ = dataStore.GetCheck(created.ID) + if check.AuthType != "" || check.AuthValue != "" { + t.Fatalf("Auth after clear = (%q, %q), want empty", check.AuthType, check.AuthValue) + } + + badForm := url.Values{"auth_type": []string{"hmac"}} + badReq := httptest.NewRequest(http.MethodPost, "/dashboard/checks/"+created.ID+"/auth", bytes.NewBufferString(badForm.Encode())) + badReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + badRes := httptest.NewRecorder() + server.Handler().ServeHTTP(badRes, badReq) + if badRes.Code != http.StatusBadRequest { + t.Fatalf("invalid auth_type status = %d, want %d", badRes.Code, http.StatusBadRequest) + } +} diff --git a/internal/httpapi/templates/detail.html b/internal/httpapi/templates/detail.html index ecc8d4d..b5e3bb0 100644 --- a/internal/httpapi/templates/detail.html +++ b/internal/httpapi/templates/detail.html @@ -289,6 +289,57 @@