Help us shape the future of Go! Take the 2026 Go Developer Survey

webhook

package
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Index

Constants

View Source
const DeliveryTTL = 48 * time.Hour
View Source
const LeaseDuration = 5 * time.Minute

LeaseDuration is how long a leased delivery is hidden from other workers. On a clean delivery the row's next_retry_at is overwritten by the success path (MarkDelivered) or a real backoff (RecordFailure). The lease only matters as a recovery mechanism when a worker dies mid-delivery — after LeaseDuration the row becomes eligible again and another worker picks it up. Long enough that a legitimate slow webhook won't be double-fired, short enough that a crashed worker doesn't strand its rows for hours.

Variables

This section is empty.

Functions

func IsDisallowedWebhookIP

func IsDisallowedWebhookIP(ip net.IP) bool

IsDisallowedWebhookIP reports whether ip is in a range a webhook must never reach (loopback, RFC-1918 private, link-local incl. the cloud metadata endpoint 169.254.169.254, CGNAT shared space, multicast, unspecified, IPv6 ULA). Shared by registration-time validation (agent.ValidateWebhookURL) and the delivery-time dial guard below so the two can never drift — closing the DNS-rebinding window where a host validates as public at registration then re-resolves to an internal address before delivery.

Types

type AutoDisableWorker

type AutoDisableWorker struct {
	// contains filtered or unexported fields
}

AutoDisableWorker scans for chronically-failing webhooks and disables them, and clears expired signing_secret_prev rows past their 24h grace window. Decision #12 in the design.

The two passes share a worker because they're both cheap, idempotent, and run on the same low cadence. The schedule is owned by River (webhookdelivery.MaintenanceJobs, a periodic on QueueMaintenance) which drives Tick; this type is the sweep body.

func NewAutoDisableWorker

func NewAutoDisableWorker(store *identity.Store) *AutoDisableWorker

NewAutoDisableWorker constructs the sweep. River drives Tick on a periodic schedule; tests can call Tick directly.

func (*AutoDisableWorker) Tick

func (w *AutoDisableWorker) Tick(ctx context.Context)

Tick runs both maintenance passes once. Driven by the River periodic (and directly by tests).

type Delivery

type Delivery struct {
	AgentID       string     `json:"agent_id"`
	MessageID     string     `json:"message_id"`
	Status        string     `json:"status"`
	Attempts      int        `json:"attempts"`
	MaxAttempts   int        `json:"max_attempts"`
	LastError     string     `json:"last_error"`
	LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
	NextRetryAt   time.Time  `json:"next_retry_at"`
	CreatedAt     time.Time  `json:"created_at"`
	ExpiresAt     time.Time  `json:"expires_at"`
}

type DeliveryOutcome

type DeliveryOutcome struct {
	Success    bool
	StatusCode int
	Error      string
}

DeliveryOutcome is what the deliverer returns to the caller for status accounting. statusCode is 0 when there was no HTTP response (connection error, timeout, DNS failure).

type DeliveryStore

type DeliveryStore struct {
	// contains filtered or unexported fields
}

func NewDeliveryStore

func NewDeliveryStore(pool *pgxpool.Pool) *DeliveryStore

func (*DeliveryStore) CreateDelivery

func (s *DeliveryStore) CreateDelivery(ctx context.Context, messageID string, lastError string) (*Delivery, error)

func (*DeliveryStore) DeleteExpiredDeliveries

func (s *DeliveryStore) DeleteExpiredDeliveries(ctx context.Context) (int64, error)

func (*DeliveryStore) GetPendingDeliveries

func (s *DeliveryStore) GetPendingDeliveries(ctx context.Context, limit int) ([]Delivery, error)

GetPendingDeliveries atomically claims up to `limit` due deliveries. Each returned row's next_retry_at is pushed by LeaseDuration so other workers (in this process or a different replica) won't grab the same row. The standard `WHERE status='pending' AND next_retry_at <= now()` filter then naturally excludes leased rows.

This must run inside a transaction: `FOR UPDATE SKIP LOCKED` only holds the row lock for the lifetime of the surrounding transaction. pool.Query (autocommit) would release the lock as soon as the SELECT completed, leaving a window where two callers could each return the same row.

func (*DeliveryStore) MarkAttemptFailed

func (s *DeliveryStore) MarkAttemptFailed(ctx context.Context, messageID, errMsg string, nextRetry time.Time) error

func (*DeliveryStore) MarkDelivered

func (s *DeliveryStore) MarkDelivered(ctx context.Context, messageID string) error

func (*DeliveryStore) MarkFailed

func (s *DeliveryStore) MarkFailed(ctx context.Context, messageID, errMsg string) error

type Payload

type Payload struct {
	MessageID      string `json:"message_id,omitempty"`
	ConversationID string `json:"conversation_id,omitempty"`
	From           string `json:"from"`
	// To is the parsed To: header from the inbound message — every fan-out
	// delivery for one inbound message carries the same list. delivered_to is
	// this delivery's per-agent target — the envelope Delivered-To address
	// (always one of the addressed agents, not necessarily in To: when the
	// agent was Bcc'd).
	To []string `json:"to"`
	CC []string `json:"cc,omitempty"`
	// ReplyTo is the parsed Reply-To: header (RFC 5322 § 3.6.2 — list, single
	// value is typical but multi is legal). Empty list when the header is
	// absent; the relay never silently falls back to From: so consumers can
	// distinguish "sender didn't request a different reply mailbox" from
	// "sender explicitly named these mailboxes".
	ReplyTo     []string          `json:"reply_to,omitempty"`
	Recipient   string            `json:"delivered_to"`
	RawMessage  []byte            `json:"raw_message"`
	AuthHeaders map[string]string `json:"auth_headers"`
	ReceivedAt  time.Time         `json:"received_at"`
}

type SubscriberDeliverer

type SubscriberDeliverer struct {
	// contains filtered or unexported fields
}

SubscriberDeliverer performs the HTTP POST for a webhook_subscriber_deliveries row, signs the request with the per-webhook HMAC secret, and reports success / failure to the caller. Distinct from the retired legacy per-agent delivery path.

Slice 1 carries only the current secret. Slice 4 will extend this to dual-sign during the 24h rotation grace window.

func NewSubscriberDeliverer

func NewSubscriberDeliverer(requireHTTPS bool, internalSinkURL string) *SubscriberDeliverer

NewSubscriberDeliverer constructs the deliverer with the 15s per-attempt timeout chosen in design decision #6.

requireHTTPS gates against plaintext URLs in production. The same flag installs a dial-time IP guard (guardedDialControl): registration-time ValidateWebhookURL validates DNS once, but a hostname can re-resolve to an internal IP before delivery (DNS rebinding). The guard re-checks the actual resolved IP at connect time, closing that window. It is gated to production so local/CI deliveries to 127.0.0.1 still work.

internalSinkURL (usually empty) names ONE trusted internal sink — the e2a-prober's /sink — reached over plain HTTP on an internal host. Deliveries to that EXACT URL bypass the HTTPS + SSRF guards via a separate exemptClient. This is safe because: (1) the value is server-operator config, never attacker input; (2) it is matched by exact string equality, so it grants access to no other internal address; and (3) the probe webhook that targets it is created by the privileged prober `seed`, not the public registration API (which rejects http:// + private hosts). Empty disables the exemption entirely.

func (*SubscriberDeliverer) Deliver

func (d *SubscriberDeliverer) Deliver(ctx context.Context, url string, body []byte, secret, secretPrev, eventType, schemaVersion string) DeliveryOutcome

Deliver performs one POST attempt. It signs the request body with the supplied HMAC secret in Stripe-style header format:

X-E2A-Signature: t=<unix>,v1=<hex(hmac-sha256(secret, "<t>.<body>"))>

secretPrev (if non-empty) adds a second v1=... signature for the receiver to verify against during the 24h rotation grace window. Slice 1 always passes secretPrev="" (no grace logic yet); slice 4 wires this up.

2xx responses are success. Anything else (including 3xx, since redirects are blocked) is a failure with the HTTP status code reported back. Connection errors return Success=false and StatusCode=0.

type SubscriberDelivery

type SubscriberDelivery struct {
	ID             string
	WebhookID      string
	EventType      string
	EventPayload   []byte // pre-marshalled envelope bytes; POSTed verbatim
	MessageID      *string
	Status         string // pending | delivered | failed
	Attempts       int
	MaxAttempts    int
	LastError      string
	LastStatusCode *int
	LastAttemptAt  *time.Time
	NextRetryAt    time.Time
	CreatedAt      time.Time
	ExpiresAt      time.Time
}

SubscriberDelivery is one row in webhook_subscriber_deliveries. Distinct from the legacy Delivery struct (which is keyed by message_id and tracks legacy single-URL delivery state).

type SubscriberStore

type SubscriberStore struct {
	// contains filtered or unexported fields
}

SubscriberStore manages webhook_subscriber_deliveries. Parallel to the legacy DeliveryStore (which manages webhook_deliveries).

func NewSubscriberStore

func NewSubscriberStore(pool *pgxpool.Pool) *SubscriberStore

func (*SubscriberStore) DeleteExpiredSubscriberDeliveries

func (s *SubscriberStore) DeleteExpiredSubscriberDeliveries(ctx context.Context) (int, error)

func (*SubscriberStore) GetSubscriberDeliveryByID

func (s *SubscriberStore) GetSubscriberDeliveryByID(ctx context.Context, deliveryID string) (*SubscriberDelivery, error)

GetSubscriberDeliveryByID loads a single delivery row by id — the River DeliverWorker's entry point (it holds only the delivery id and reads the payload + webhook_id here). Returns pgx.ErrNoRows if the row is gone.

func (*SubscriberStore) InsertPendingForTest

func (s *SubscriberStore) InsertPendingForTest(ctx context.Context, webhookID, eventType string, envelope []byte) (string, error)

InsertPendingForTest creates a single delivery row tied to the given webhook + event type with the supplied envelope bytes. The retry worker picks it up on the next tick. Used by the POST /v1/webhooks/{id}/test endpoint to schedule a one-off delivery without going through the publisher's filter-matching path.

func (*SubscriberStore) ListDeliveriesByWebhook

func (s *SubscriberStore) ListDeliveriesByWebhook(ctx context.Context, webhookID, status string, limit int, afterCreatedAt time.Time, afterID string) ([]SubscriberDelivery, error)

ListDeliveriesByWebhook returns one page of delivery rows for the webhook, most-recent first, keyset-paginated on (created_at, id). When status is non-empty, restricts to that status (pending|delivered|failed). The caller passes limit (fetch limit+1 to detect a further page) and the after-key from the previous page's last row (zero afterCreatedAt = first page). The delivery log grows unbounded on a busy webhook, so it needs real pagination rather than silently truncating at a fixed cap. Limit is bounded by the caller; this method does not enforce a cap.

func (*SubscriberStore) MarkDelivered

func (s *SubscriberStore) MarkDelivered(ctx context.Context, deliveryID string, statusCode int) error

MarkDelivered transitions a row to status='delivered' and stamps last_attempt_at + last_status_code. Also bumps webhooks.last_delivered_at in the same transaction so list views show the freshest activity.

func (*SubscriberStore) MarkSubscriberFailed

func (s *SubscriberStore) MarkSubscriberFailed(ctx context.Context, deliveryID string, attemptN int, errMsg string, statusCode int) error

MarkSubscriberFailed transitions a delivery to terminal 'failed' — called by the DeliverWorker on the last (River-exhausted) attempt, and as an ErrorHandler backstop on discard. Records the final attempt count + error.

func (*SubscriberStore) RecordSubscriberAttempt

func (s *SubscriberStore) RecordSubscriberAttempt(ctx context.Context, deliveryID string, attemptN int, errMsg string, statusCode int) error

RecordSubscriberAttempt records ONE failed attempt without deciding retry or terminality — under River the retry schedule and the give-up decision belong to the job, not the store. Status stays 'pending'; attempts/last_error/ last_status_code/last_attempt_at are updated. (Contrast RecordAttemptFailure, the hand-rolled path, which also computed next_retry_at and flipped to 'failed' at the cap — retired with the legacy worker.) attemptN is the River job's attempt number, written verbatim so the history API's attempts count matches River's.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL