Documentation
¶
Overview ¶
Package webhooks receives Git push webhooks from GitHub, GitLab and Gitea and turns them into deploy triggers. Every delivery is authenticated before a single byte of the payload is interpreted (build plan §2, "HMAC from day one"):
- GitHub and Gitea sign the body with HMAC-SHA256 under a per-app secret; the signature is compared with hmac.Equal.
- GitLab does not sign; it sends the secret verbatim in X-Gitlab-Token, which is compared in constant time against SHA-256 digests so neither content nor length leaks.
- An app with no secret configured is never accepted: unsigned webhooks do not exist here.
- Replay protection keys on the SHA-256 of the signed body, not on the provider's delivery-id header (which the signature does not cover). A replayed push answers 202 {"ignored":"duplicate"} and is audited as denied (D-020).
The package never logs, audits or returns in an error any secret, signature or payload byte (golden rule 3). Logs carry only provider, app name, delivery id and outcome.
Index ¶
- Constants
- Variables
- func DeliveryID(p Provider, headers http.Header, body []byte) string
- func IgnoreReason(err error) string
- func IsPing(p Provider, headers http.Header) bool
- func ReadBody(r *http.Request) ([]byte, error)
- func Verify(p Provider, headers http.Header, body, secret []byte) error
- type Deduper
- type Event
- type Handler
- type Provider
Constants ¶
const ( DefaultDedupeTTL = 24 * time.Hour DefaultDedupeMax = 10_000 )
Deduper defaults used when NewDeduper is given non-positive values.
const ( ActionRejected = "webhook.rejected" ActionReplayed = "webhook.replayed" ActionAccepted = "webhook.accepted" )
Audit actions emitted by the handler.
const MaxBody = 1 << 20
MaxBody is the largest accepted request body. Push payloads are a few KiB; 1 MiB leaves room for large commit lists while bounding memory per request.
const Pattern = "/api/v1/webhooks/{provider}/{app}"
Pattern is the chi route the Handler serves.
Variables ¶
var ( // ErrMissingSignature means the provider's signature (or token) header is absent. ErrMissingSignature = errors.New("webhooks: missing signature") // ErrBadSignature means the signature/token did not verify, or no secret is configured. ErrBadSignature = errors.New("webhooks: bad signature") // ErrMissingEvent means the provider's event-type header (X-GitHub-Event, …) is absent. ErrMissingEvent = errors.New("webhooks: missing event header") // ErrIgnoredEvent means the delivery verified but is not a branch push we deploy from. ErrIgnoredEvent = errors.New("webhooks: ignored event") // ErrInvalidPayload means the body is not the JSON shape the provider documents. ErrInvalidPayload = errors.New("webhooks: invalid payload") // ErrPayloadTooLarge means the body exceeded MaxBody. ErrPayloadTooLarge = errors.New("webhooks: payload too large") )
Sentinel errors.
var ErrUnknownApp = errors.New("webhooks: unknown app")
ErrUnknownApp is returned by Handler.Lookup when the app does not exist or has no git source. The handler answers 404 for it. Any other Lookup error is treated as an infrastructure failure (500), so callers must wrap their own not-found conditions with this sentinel.
Functions ¶
func DeliveryID ¶
DeliveryID returns the provider's delivery id (X-GitHub-Delivery, X-Gitea-Delivery, X-Gitlab-Event-UUID) for logs and audit events. When the header is absent or malformed the hex SHA-256 of the body is used instead. The header is not covered by the signature, so the value is informational only: replay protection never keys on it (see Handler.Dedupe).
func IgnoreReason ¶
IgnoreReason returns the short reason attached to an ErrIgnoredEvent ("not a push", "tag", "branch deleted", …) or "" when err is not an ignored-event error.
func IsPing ¶
IsPing reports whether the delivery is a provider "ping" (GitHub sends one when a hook is created; Gitea can too). Pings are still signed, so call Verify first.
func ReadBody ¶
ReadBody reads at most MaxBody bytes of the request body. Larger bodies yield ErrPayloadTooLarge; the remaining bytes are not consumed.
func Verify ¶
Verify authenticates a delivery. It returns nil only when the provider's event header is present, the signature (or token) header is present and well-formed, the secret is non-empty and the credential matches. The HMAC over the body is always computed before any check so that the time taken does not depend on which check fails; comparisons are constant time.
Errors never contain header values, the secret or the body.
Types ¶
type Deduper ¶
type Deduper struct {
// contains filtered or unexported fields
}
Deduper remembers delivery keys it has seen for replay protection. It is bounded (oldest first-seen key evicted) and time-limited (a key is forgotten ttl after it was first seen — a replay does not extend the window). Safe for concurrent use.
func NewDeduper ¶
NewDeduper returns a Deduper keeping at most max keys for ttl each.
type Event ¶
type Event struct {
Provider Provider
DeliveryID string
// Ref is the full ref (refs/heads/<branch>); Branch is the part after refs/heads/.
Ref string
Branch string
// Before and After are the commit SHAs the push moved the branch from and to.
Before, After string
// HeadMessage is the head commit message, at most maxMessage bytes, control characters
// other than newline and tab removed.
HeadMessage string
// RepoCloneURL is the HTTP(S) clone URL from the payload; RepoSSHURL the SSH one.
RepoCloneURL string
RepoSSHURL string
// Pusher is the login of the user who pushed.
Pusher string
// Deleted is true when the push deleted the branch (the event is then ignored).
Deleted bool
}
Event is the provider-agnostic view of a verified branch push.
func Parse ¶
Parse interprets a verified delivery as a branch push. Anything that is not a push to a branch — pings, other event types, tag pushes, branch deletions — returns an error that unwraps to ErrIgnoredEvent (see IgnoreReason). Malformed bodies return ErrInvalidPayload. Call Verify first; Parse trusts nothing about the sender.
type Handler ¶
type Handler struct {
// Lookup returns the app's webhook secret, deploy branch and configured provider.
// It returns ErrUnknownApp (possibly wrapped) when the app has no git source.
Lookup func(ctx context.Context, app string) (secret []byte, branch string, provider Provider, err error)
// Trigger enqueues a deploy for a verified, de-duplicated push. Its error is logged, so it
// must not carry secret values.
Trigger func(ctx context.Context, app string, ev Event) error
// Dedupe rejects replays of an already-processed push. The key is derived from the signed
// body (see dedupeKey), never from the delivery-id header, which is not covered by the
// signature. Nil disables replay protection (not recommended).
Dedupe *Deduper
Logger *slog.Logger
Audit audit.Sink
// contains filtered or unexported fields
}
Handler serves push webhooks. All fields except Dedupe, Logger and Audit are required.
type Provider ¶
type Provider string
Provider identifies the Git hosting service that sent a webhook.
const ( ProviderGitHub Provider = "github" ProviderGitLab Provider = "gitlab" ProviderGitea Provider = "gitea" )
Supported providers. The string values are the {provider} URL segment and the git_sources.provider column.
func ParseProvider ¶
ParseProvider maps a URL segment to a Provider.