Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
98 changes: 98 additions & 0 deletions internal/httpapi/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"html/template"
"net/http"
"sort"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
124 changes: 124 additions & 0 deletions internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading