check / check (push) Waiting to run
On the receiver route the access log and the per-entrypoint 429 line name the route pattern whatever the status. A request chi never routed, such as a dev-mode CORS preflight to the receiver, logs (unmatched) whatever the status. The received line names the entrypoint and webhook IDs, and the unknown-entrypoint line logs no path. The pattern is middleware.ReceiverRoute, which routes.go registers the receiver with. The README says the proxy's access log holds the UUID and must be protected like the URL, and its access-log, rate-limit, Sentry and log-size passages now match the code. Model: opus-5-5
448 lines
11 KiB
Go
448 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/delivery"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
)
|
|
|
|
const (
|
|
// maxWebhookBodySize is the maximum allowed webhook
|
|
// request body (1 MB).
|
|
maxWebhookBodySize = 1 << maxBodyShift
|
|
)
|
|
|
|
// HandleWebhook handles incoming webhook requests at entrypoint
|
|
// URLs.
|
|
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(
|
|
w,
|
|
"Method Not Allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
entrypointUUID := chi.URLParam(r, "uuid")
|
|
if entrypointUUID == "" {
|
|
http.NotFound(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
entrypoint, ok := h.lookupEntrypoint(
|
|
w, r, entrypointUUID,
|
|
)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Logged only once the lookup has found the entrypoint, so
|
|
// an invented path writes no INFO line; a miss is logged at
|
|
// DEBUG. The entrypoint is named by its ID: the UUID is its
|
|
// credential and is never logged.
|
|
h.log.Info("webhook request received",
|
|
"entrypoint_id", entrypoint.ID,
|
|
"webhook_id", entrypoint.WebhookID,
|
|
"method", r.Method,
|
|
"remoteIP", middleware.RemoteIP(r),
|
|
"clientIP", middleware.ClientIP(r),
|
|
)
|
|
|
|
if !entrypoint.Active {
|
|
http.Error(w, "Gone", http.StatusGone)
|
|
|
|
return
|
|
}
|
|
|
|
h.processWebhookRequest(w, r, entrypoint)
|
|
}
|
|
}
|
|
|
|
// processWebhookRequest reads the body, serializes headers, loads
|
|
// targets, and delivers the event.
|
|
//
|
|
// Nothing about the request itself is authenticated: the entrypoint
|
|
// UUID in the path is the credential, and reaching here means it
|
|
// matched an active entrypoint.
|
|
func (h *Handlers) processWebhookRequest(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
entrypoint database.Entrypoint,
|
|
) {
|
|
body, ok := h.readWebhookBody(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
headersJSON, err := json.Marshal(r.Header)
|
|
if err != nil {
|
|
h.receiverError(w, "failed to serialize headers", err)
|
|
|
|
return
|
|
}
|
|
|
|
targets, err := h.loadActiveTargets(entrypoint.WebhookID)
|
|
if err != nil {
|
|
h.receiverError(w, "failed to query targets", err)
|
|
|
|
return
|
|
}
|
|
|
|
h.createAndDeliverEvent(
|
|
w, r, entrypoint, body, headersJSON, targets,
|
|
)
|
|
}
|
|
|
|
// loadActiveTargets returns all active targets for a webhook.
|
|
func (h *Handlers) loadActiveTargets(
|
|
webhookID string,
|
|
) ([]database.Target, error) {
|
|
var targets []database.Target
|
|
|
|
err := h.db.DB().Where(
|
|
"webhook_id = ? AND active = ?",
|
|
webhookID, true,
|
|
).Find(&targets).Error
|
|
|
|
return targets, err
|
|
}
|
|
|
|
// lookupEntrypoint finds an entrypoint by UUID path.
|
|
func (h *Handlers) lookupEntrypoint(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
entrypointUUID string,
|
|
) (database.Entrypoint, bool) {
|
|
var entrypoint database.Entrypoint
|
|
|
|
result := h.db.DB().Where(
|
|
"path = ?", entrypointUUID,
|
|
).First(&entrypoint)
|
|
if result.Error != nil {
|
|
// Not even an unknown UUID is logged. The receiver's log
|
|
// lines never carry the path segment, which is a credential
|
|
// whenever it names an entrypoint.
|
|
h.log.Debug("entrypoint not found")
|
|
http.NotFound(w, r)
|
|
|
|
return entrypoint, false
|
|
}
|
|
|
|
return entrypoint, true
|
|
}
|
|
|
|
// readWebhookBody reads and validates the request body size. This is
|
|
// the receiver's only body cap: /h/{uuid} has no MaxBodySize
|
|
// middleware (see Server.setupWebhookRoutes).
|
|
func (h *Handlers) readWebhookBody(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) ([]byte, bool) {
|
|
body, err := io.ReadAll(
|
|
io.LimitReader(r.Body, maxWebhookBodySize+1),
|
|
)
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to read request body", "error", err,
|
|
)
|
|
http.Error(
|
|
w, "Bad request", http.StatusBadRequest,
|
|
)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
if len(body) > maxWebhookBodySize {
|
|
http.Error(
|
|
w,
|
|
"Request body too large",
|
|
http.StatusRequestEntityTooLarge,
|
|
)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
return body, true
|
|
}
|
|
|
|
// createAndDeliverEvent stores the received event, fans it out to the
|
|
// webhook's targets, and answers the sender.
|
|
func (h *Handlers) createAndDeliverEvent(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
entrypoint database.Entrypoint,
|
|
body, headersJSON []byte,
|
|
targets []database.Target,
|
|
) {
|
|
event, tasks, err := h.createAndFanOut(
|
|
requestEventSource(r, entrypoint, headersJSON, body),
|
|
targets,
|
|
)
|
|
if err != nil {
|
|
h.receiverError(w, "failed to store webhook event", err)
|
|
|
|
return
|
|
}
|
|
|
|
h.finishWebhookResponse(w, event, entrypoint, tasks)
|
|
}
|
|
|
|
// receiverError logs an error and answers the sender with a plain-text
|
|
// 500. The receiver's answers are for programs, so it never sends the
|
|
// error page the web UI uses.
|
|
func (h *Handlers) receiverError(
|
|
w http.ResponseWriter, msg string, err error,
|
|
) {
|
|
h.log.Error(msg, "error", err)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
}
|
|
|
|
// eventSource carries the fields a new event is built from. The
|
|
// receiver fills it from the live request; the resubmit handler fills
|
|
// it from a stored event. Both then go through createAndFanOut, so an
|
|
// event is constructed and fanned out in one place however it entered
|
|
// the system.
|
|
type eventSource struct {
|
|
WebhookID string
|
|
EntrypointID string
|
|
Method string
|
|
RawQuery string
|
|
HeadersJSON string
|
|
ContentType string
|
|
Body []byte
|
|
|
|
// ResubmittedFromID names the event this one copies. Only the
|
|
// resubmit path sets it.
|
|
ResubmittedFromID *string
|
|
}
|
|
|
|
// event builds the row this source stores.
|
|
func (s eventSource) event() *database.Event {
|
|
return &database.Event{
|
|
WebhookID: s.WebhookID,
|
|
EntrypointID: s.EntrypointID,
|
|
Method: s.Method,
|
|
RawQuery: s.RawQuery,
|
|
Headers: s.HeadersJSON,
|
|
Body: string(s.Body),
|
|
BodyBytes: int64(len(s.Body)),
|
|
ContentType: s.ContentType,
|
|
ResubmittedFromID: s.ResubmittedFromID,
|
|
}
|
|
}
|
|
|
|
// requestEventSource describes the event a live receiver request
|
|
// stores.
|
|
func requestEventSource(
|
|
r *http.Request,
|
|
entrypoint database.Entrypoint,
|
|
headersJSON, body []byte,
|
|
) eventSource {
|
|
return eventSource{
|
|
WebhookID: entrypoint.WebhookID,
|
|
EntrypointID: entrypoint.ID,
|
|
Method: r.Method,
|
|
RawQuery: r.URL.RawQuery,
|
|
HeadersJSON: string(headersJSON),
|
|
ContentType: r.Header.Get("Content-Type"),
|
|
Body: body,
|
|
}
|
|
}
|
|
|
|
// createAndFanOut writes the event and one pending delivery per target,
|
|
// and adds them to the webhook's running totals, in a single
|
|
// transaction, then hands the tasks to the delivery engine. Every
|
|
// event is created here, received or resubmitted, so a resubmitted
|
|
// event is retried, SSRF-guarded and circuit-broken exactly as a
|
|
// received one is. Per-delivery replay is the one other path that
|
|
// creates a delivery: it adds one to an existing event without
|
|
// coming through here.
|
|
//
|
|
// The tasks are returned as well as queued, so a caller can report how
|
|
// many targets the event went to.
|
|
func (h *Handlers) createAndFanOut(
|
|
src eventSource,
|
|
targets []database.Target,
|
|
) (*database.Event, []delivery.Task, error) {
|
|
webhookDB, err := h.dbMgr.GetDB(src.WebhookID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"getting webhook database: %w", err,
|
|
)
|
|
}
|
|
|
|
tx := webhookDB.Begin()
|
|
if tx.Error != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"beginning transaction: %w", tx.Error,
|
|
)
|
|
}
|
|
|
|
event := src.event()
|
|
|
|
err = tx.Create(event).Error
|
|
if err != nil {
|
|
tx.Rollback()
|
|
|
|
return nil, nil, fmt.Errorf("creating event: %w", err)
|
|
}
|
|
|
|
tasks, err := buildDeliveryTasks(
|
|
tx, event, targets, inlineBody(src.Body),
|
|
)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
|
|
return nil, nil, err
|
|
}
|
|
|
|
err = database.AddEventTotals(tx, database.EventTotals{
|
|
Events: 1, LastEventAt: &event.CreatedAt,
|
|
})
|
|
if err != nil {
|
|
tx.Rollback()
|
|
|
|
return nil, nil, err
|
|
}
|
|
|
|
// A resubmitted copy did not arrive on its entrypoint's URL, so it
|
|
// leaves the entrypoint's last event as it is.
|
|
if src.ResubmittedFromID == nil {
|
|
err = database.AddEntrypointTotals(tx, database.EntrypointTotals{
|
|
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
|
|
})
|
|
if err != nil {
|
|
tx.Rollback()
|
|
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
err = tx.Commit().Error
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"committing transaction: %w", err,
|
|
)
|
|
}
|
|
|
|
// Counted here, after the commit: an event exists once it is
|
|
// durably stored, which is what the delivery counters are
|
|
// compared against on a dashboard. A resubmitted event counts
|
|
// too, because it produces deliveries that the delivery side
|
|
// counts; the resubmit counter is what separates the two.
|
|
h.mtr.EventReceived()
|
|
|
|
if len(tasks) > 0 {
|
|
h.notifier.Notify(tasks)
|
|
}
|
|
|
|
return event, tasks, nil
|
|
}
|
|
|
|
// inlineBody returns a pointer to body as a string if it fits
|
|
// within the inline size limit, or nil otherwise.
|
|
func inlineBody(body []byte) *string {
|
|
if len(body) < delivery.MaxInlineBodySize {
|
|
s := string(body)
|
|
|
|
return &s
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// finishWebhookResponse logs the event and writes the HTTP response.
|
|
func (h *Handlers) finishWebhookResponse(
|
|
w http.ResponseWriter,
|
|
event *database.Event,
|
|
entrypoint database.Entrypoint,
|
|
tasks []delivery.Task,
|
|
) {
|
|
h.log.Info("webhook event created",
|
|
"event_id", event.ID,
|
|
"webhook_id", entrypoint.WebhookID,
|
|
"entrypoint_id", entrypoint.ID,
|
|
"target_count", len(tasks),
|
|
)
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
_, err := w.Write([]byte(`{"status":"ok"}`))
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to write response", "error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// buildDeliveryTasks creates one pending delivery per target in the
|
|
// transaction, adds each to its target's totals, and returns the tasks
|
|
// for the delivery engine. The caller owns the transaction and rolls
|
|
// it back on error.
|
|
func buildDeliveryTasks(
|
|
tx *gorm.DB,
|
|
event *database.Event,
|
|
targets []database.Target,
|
|
bodyPtr *string,
|
|
) ([]delivery.Task, error) {
|
|
tasks := make([]delivery.Task, 0, len(targets))
|
|
|
|
for i := range targets {
|
|
dlv := &database.Delivery{
|
|
EventID: event.ID,
|
|
TargetID: targets[i].ID,
|
|
Status: database.DeliveryStatusPending,
|
|
}
|
|
|
|
err := tx.Create(dlv).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"creating delivery for target %s: %w",
|
|
targets[i].ID, err,
|
|
)
|
|
}
|
|
|
|
err = database.AddTargetTotals(tx, database.TargetTotals{
|
|
TargetID: targets[i].ID, Deliveries: 1,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tasks = append(tasks, delivery.Task{
|
|
DeliveryID: dlv.ID,
|
|
EventID: event.ID,
|
|
WebhookID: event.WebhookID,
|
|
EntrypointID: event.EntrypointID,
|
|
TargetID: targets[i].ID,
|
|
TargetName: targets[i].Name,
|
|
TargetType: targets[i].Type,
|
|
TargetConfig: targets[i].Config,
|
|
MaxRetries: targets[i].MaxRetries,
|
|
Method: event.Method,
|
|
RawQuery: event.RawQuery,
|
|
Headers: event.Headers,
|
|
ContentType: event.ContentType,
|
|
Body: bodyPtr,
|
|
AttemptNum: 1,
|
|
})
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|