mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-12 05:48:58 +00:00
4dfeb9848e
- internal/events: in-process pub/sub bus; GET /api/v1/events SSE stream - internal/notify/webhooks.go: signed outgoing webhook dispatcher w/ retry + delivery log - internal/api/tags.go, search.go, bulk.go: cross-remote tag aggregation, global search, fleet-wide bulk guest actions - internal/auth/sessions.go: session listing/revocation (self-service + admin), extends the existing stateful session table - internal/poller/certificates.go: cert-expiry + connection-staleness alerting via the existing alert_rules pipeline - web: useEventStream hook, GlobalSearch + BulkOperationsPage (not yet wired into nav), SessionsCard on ProfilePage
319 lines
9.6 KiB
Go
319 lines
9.6 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"ferrum/internal/events"
|
|
"ferrum/internal/notify"
|
|
)
|
|
|
|
// webhookDTO is what the list/create/update endpoints return. Secret is
|
|
// included only right after creation (see createWebhook) — list responses
|
|
// never echo it back, the same convention apikeys.go uses for the plaintext
|
|
// key.
|
|
type webhookDTO struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Secret string `json:"secret,omitempty"`
|
|
EventTypes []string `json:"eventTypes"`
|
|
Active bool `json:"active"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
// knownEventTypes is the catalog surfaced to the settings UI for building a
|
|
// subscription's event-type picker; also used to validate a request's
|
|
// eventTypes.
|
|
var knownEventTypes = []events.Type{
|
|
events.TypeAlertTriggered,
|
|
events.TypeAlertResolved,
|
|
events.TypeConnectionUp,
|
|
events.TypeConnectionDown,
|
|
events.TypeGuestPowerChanged,
|
|
events.TypeTaskCompleted,
|
|
events.TypeHAStatusChanged,
|
|
}
|
|
|
|
func validEventType(t string) bool {
|
|
for _, known := range knownEventTypes {
|
|
if string(known) == t {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) listWebhooks(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := s.db.QueryContext(r.Context(),
|
|
`SELECT id, name, url, event_types, active, created_at, updated_at FROM webhook_subscriptions ORDER BY created_at DESC`)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []webhookDTO{}
|
|
for rows.Next() {
|
|
var dto webhookDTO
|
|
var eventTypesJSON string
|
|
var active int
|
|
if err := rows.Scan(&dto.ID, &dto.Name, &dto.URL, &eventTypesJSON, &active, &dto.CreatedAt, &dto.UpdatedAt); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
dto.Active = active == 1
|
|
dto.EventTypes = decodeEventTypes(eventTypesJSON)
|
|
out = append(out, dto)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func decodeEventTypes(raw string) []string {
|
|
var types []string
|
|
_ = json.Unmarshal([]byte(raw), &types) // malformed row → treat as "no filter" for display purposes
|
|
return types
|
|
}
|
|
|
|
type webhookRequest struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
EventTypes []string `json:"eventTypes"`
|
|
Active *bool `json:"active,omitempty"` // nil on create means true
|
|
}
|
|
|
|
func (req webhookRequest) validate() (string, bool) {
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
return "name is required", false
|
|
}
|
|
u, err := url.Parse(req.URL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return "url must be a valid http(s) URL", false
|
|
}
|
|
for _, t := range req.EventTypes {
|
|
if !validEventType(t) {
|
|
return "unknown event type: " + t, false
|
|
}
|
|
}
|
|
return "", true
|
|
}
|
|
|
|
// generateWebhookSecret returns a random 32-byte hex string used as the
|
|
// HMAC signing key — generated server-side (like an API key) rather than
|
|
// admin-supplied, so it can't be a guessable/reused value.
|
|
func generateWebhookSecret() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
func (s *Server) createWebhook(w http.ResponseWriter, r *http.Request) {
|
|
var req webhookRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErrorMsg(w, http.StatusBadRequest, "expected a JSON object")
|
|
return
|
|
}
|
|
if msg, ok := req.validate(); !ok {
|
|
writeErrorMsg(w, http.StatusBadRequest, msg)
|
|
return
|
|
}
|
|
secret, err := generateWebhookSecret()
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
active := true
|
|
if req.Active != nil {
|
|
active = *req.Active
|
|
}
|
|
eventTypesJSON, err := json.Marshal(req.EventTypes)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
u := userFromContext(r)
|
|
var createdBy *string
|
|
if u != nil {
|
|
createdBy = &u.ID
|
|
}
|
|
|
|
id := uuid.NewString()
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if _, err := s.db.ExecContext(r.Context(), `
|
|
INSERT INTO webhook_subscriptions (id, name, url, secret, event_types, active, created_by, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
id, req.Name, req.URL, secret, string(eventTypesJSON), boolToInt(active), createdBy, now, now,
|
|
); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
s.audit(r, "webhook.create", "settings", req.Name)
|
|
writeJSON(w, http.StatusCreated, webhookDTO{
|
|
ID: id, Name: req.Name, URL: req.URL, Secret: secret,
|
|
EventTypes: req.EventTypes, Active: active, CreatedAt: now, UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
func (s *Server) updateWebhook(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var req webhookRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErrorMsg(w, http.StatusBadRequest, "expected a JSON object")
|
|
return
|
|
}
|
|
if msg, ok := req.validate(); !ok {
|
|
writeErrorMsg(w, http.StatusBadRequest, msg)
|
|
return
|
|
}
|
|
active := true
|
|
if req.Active != nil {
|
|
active = *req.Active
|
|
}
|
|
eventTypesJSON, err := json.Marshal(req.EventTypes)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
res, err := s.db.ExecContext(r.Context(), `
|
|
UPDATE webhook_subscriptions SET name = ?, url = ?, event_types = ?, active = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
req.Name, req.URL, string(eventTypesJSON), boolToInt(active), now, id,
|
|
)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
writeErrorMsg(w, http.StatusNotFound, "webhook not found")
|
|
return
|
|
}
|
|
s.audit(r, "webhook.update", "settings", req.Name)
|
|
writeJSON(w, http.StatusOK, webhookDTO{
|
|
ID: id, Name: req.Name, URL: req.URL,
|
|
EventTypes: req.EventTypes, Active: active, UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
func (s *Server) deleteWebhook(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
res, err := s.db.ExecContext(r.Context(), `DELETE FROM webhook_subscriptions WHERE id = ?`, id)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
writeErrorMsg(w, http.StatusNotFound, "webhook not found")
|
|
return
|
|
}
|
|
s.audit(r, "webhook.delete", "settings", id)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
|
}
|
|
|
|
// testWebhook is POST /api/v1/settings/webhooks/{id}/test — delivers a
|
|
// synthetic events.TypeTest event to this one subscription immediately
|
|
// (bypassing its event-type filter and active flag, since testing an
|
|
// inactive subscription while setting it up is exactly the point) and
|
|
// reports whether the receiving endpoint accepted it.
|
|
func (s *Server) testWebhook(w http.ResponseWriter, r *http.Request) {
|
|
if s.webhooks == nil {
|
|
writeErrorMsg(w, http.StatusServiceUnavailable, "webhook dispatcher is not available")
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
var sub notify.WebhookSubscription
|
|
err := s.db.QueryRowContext(r.Context(),
|
|
`SELECT id, name, url, secret FROM webhook_subscriptions WHERE id = ?`, id,
|
|
).Scan(&sub.ID, &sub.Name, &sub.URL, &sub.Secret)
|
|
if err == sql.ErrNoRows {
|
|
writeErrorMsg(w, http.StatusNotFound, "webhook not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
s.audit(r, "webhook.test", "settings", sub.Name)
|
|
if err := s.webhooks.SendTestEvent(r.Context(), sub); err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{"delivered": false, "error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"delivered": true})
|
|
}
|
|
|
|
type webhookDeliveryDTO struct {
|
|
ID string `json:"id"`
|
|
EventType string `json:"eventType"`
|
|
EventID string `json:"eventId"`
|
|
Attempt int `json:"attempt"`
|
|
StatusCode *int `json:"statusCode,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Success bool `json:"success"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
// listWebhookDeliveries is GET /api/v1/settings/webhooks/{id}/deliveries —
|
|
// the last N attempts (see notify.deliveryLogRetainedRows) for one
|
|
// subscription, newest first, for diagnosing "why didn't my endpoint get
|
|
// the event".
|
|
func (s *Server) listWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var exists int
|
|
if err := s.db.QueryRowContext(r.Context(), `SELECT 1 FROM webhook_subscriptions WHERE id = ?`, id).Scan(&exists); err == sql.ErrNoRows {
|
|
writeErrorMsg(w, http.StatusNotFound, "webhook not found")
|
|
return
|
|
} else if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
rows, err := s.db.QueryContext(r.Context(), `
|
|
SELECT id, event_type, event_id, attempt, status_code, error, success, created_at
|
|
FROM webhook_deliveries WHERE subscription_id = ? ORDER BY created_at DESC`, id)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []webhookDeliveryDTO{}
|
|
for rows.Next() {
|
|
var d webhookDeliveryDTO
|
|
var success int
|
|
var errCol sql.NullString
|
|
if err := rows.Scan(&d.ID, &d.EventType, &d.EventID, &d.Attempt, &d.StatusCode, &errCol, &success, &d.CreatedAt); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
d.Success = success == 1
|
|
if errCol.Valid {
|
|
d.Error = errCol.String
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|