Skip to content

Commit

Permalink
github-events: Add multisecret support to trampoline webhook.
Browse files Browse the repository at this point in the history
Adds the ability for the trampoline to accept multiple secrets.
This tries to retain the existing behavior of the webhook assuming at
least one secret by allowing additional secrets to be specified by the
same prefix. Additional secrets must be defined and loaded out of band
of the service.
  • Loading branch information
wlynch committed Dec 11, 2024
1 parent eb19a12 commit a141602
Show file tree
Hide file tree
Showing 8 changed files with 315 additions and 82 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/go-git/go-git/v5 v5.12.0
github.com/google/go-cmp v0.6.0
github.com/google/go-github/v61 v61.0.0
github.com/jonboulle/clockwork v0.4.0
github.com/prometheus/client_golang v1.20.5
github.com/sethvargo/go-envconfig v1.1.0
github.com/shirou/gopsutil/v4 v4.24.11
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
Expand Down
1 change: 1 addition & 0 deletions modules/github-events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ No requirements.

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| <a name="input_additional_webhook_secrets"></a> [additional\_webhook\_secrets](#input\_additional\_webhook\_secrets) | Additional secrets to be used by the service. <br/><br/>- key: Local identifier for the secret. This will be prefixed with WEBHOOK\_SECRET\_ in the service's environment vars.<br/>- secret: The name of the secret in Cloud Secret Manager. Format: {secretName} if the secret is in the same project. projects/{project}/secrets/{secretName} if the secret is in a different project.<br/>- version: The version of the secret to use. Can be a number or 'latest'.<br/><br/>See https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloud_run_v2_service#nested_env for related documentation. | <pre>map(object({<br/> secret = string<br/> version = string<br/> }))</pre> | n/a | yes |
| <a name="input_deletion_protection"></a> [deletion\_protection](#input\_deletion\_protection) | Whether to enable delete protection for the service. | `bool` | `true` | no |
| <a name="input_enable_profiler"></a> [enable\_profiler](#input\_enable\_profiler) | Enable cloud profiler. | `bool` | `false` | no |
| <a name="input_ingress"></a> [ingress](#input\_ingress) | An object holding the name of the ingress service, which can be used to authorize callers to publish cloud events. | <pre>object({<br/> name = string<br/> })</pre> | n/a | yes |
Expand Down
87 changes: 15 additions & 72 deletions modules/github-events/cmd/trampoline/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,41 @@ package main

import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/chainguard-dev/clog"
_ "github.com/chainguard-dev/clog/gcp/init"
"github.com/chainguard-dev/terraform-infra-common/modules/github-events/internal/trampoline"
"github.com/chainguard-dev/terraform-infra-common/pkg/httpmetrics"
mce "github.com/chainguard-dev/terraform-infra-common/pkg/httpmetrics/cloudevents"
cloudevents "github.com/cloudevents/sdk-go/v2"
"github.com/google/go-github/v61/github"
"github.com/sethvargo/go-envconfig"
)

var env = envconfig.MustProcess(context.Background(), &struct {
Port int `env:"PORT, default=8080"`
IngressURI string `env:"EVENT_INGRESS_URI, required"`
WebhookSecret string `env:"WEBHOOK_SECRET, required"`
Port int `env:"PORT, default=8080"`
IngressURI string `env:"EVENT_INGRESS_URI, required"`
// Note: any environment variable starting with "WEBHOOK_SECRET" will be loaded as as a webhook secret to be checked.
WebhookSecret string `env:"WEBHOOK_SECRET"`
}{})

func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

// Get all secrets from the environment.
var secrets [][]byte
for _, e := range os.Environ() {
if strings.HasPrefix(e, "WEBHOOK_SECRET") {
secrets = [][]byte{[]byte(os.Getenv(e))}
}
}

go httpmetrics.ServeMetrics()
defer httpmetrics.SetupTracer(ctx)()

Expand All @@ -42,75 +50,10 @@ func main() {
clog.FatalContextf(ctx, "failed to create cloudevents client: %v", err)
}

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := clog.FromContext(ctx)

defer r.Body.Close()

// https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
payload, err := github.ValidatePayload(r, []byte(env.WebhookSecret))
if err != nil {
log.Errorf("failed to verify webhook: %v", err)
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, "failed to verify webhook: %v", err)
return
}

// https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers
t := github.WebHookType(r)
if t == "" {
log.Errorf("missing X-GitHub-Event header")
w.WriteHeader(http.StatusBadRequest)
return
}
t = "dev.chainguard.github." + t
log = log.With("event-type", t)

var msg struct {
Action string `json:"action"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
if err := json.Unmarshal(payload, &msg); err != nil {
log.Warnf("failed to unmarshal payload; action and subject will be unset: %v", err)
} else {
log = log.With("action", msg.Action, "repo", msg.Repository.FullName)
}

log.Debugf("forwarding event: %s", t)

event := cloudevents.NewEvent()
event.SetType(t)
event.SetSource(r.Host)
event.SetSubject(msg.Repository.FullName)
event.SetExtension("action", msg.Action)
if err := event.SetData(cloudevents.ApplicationJSON, struct {
When time.Time `json:"when"`
Body json.RawMessage `json:"body"`
}{
When: time.Now(),
Body: payload,
}); err != nil {
log.Errorf("failed to set data: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}

const retryDelay = 10 * time.Millisecond
const maxRetry = 3
rctx := cloudevents.ContextWithRetriesExponentialBackoff(context.WithoutCancel(ctx), retryDelay, maxRetry)
if ceresult := ceclient.Send(rctx, event); cloudevents.IsUndelivered(ceresult) || cloudevents.IsNACK(ceresult) {
log.Errorf("Failed to deliver event: %v", ceresult)
w.WriteHeader(http.StatusInternalServerError)
}
log.Debugf("event forwarded")
})

srv := &http.Server{
Addr: fmt.Sprintf(":%d", env.Port),
ReadHeaderTimeout: 10 * time.Second,
Handler: trampoline.NewServer(ceclient, secrets),
}
clog.FatalContextf(ctx, "ListenAndServe: %v", srv.ListenAndServe())
}
125 changes: 125 additions & 0 deletions modules/github-events/internal/trampoline/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package trampoline

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"time"

"github.com/chainguard-dev/clog"
cloudevents "github.com/cloudevents/sdk-go/v2"
"github.com/google/go-github/v61/github"
"github.com/jonboulle/clockwork"
)

type Server struct {
client cloudevents.Client
secrets [][]byte
clock clockwork.Clock
}

func NewServer(client cloudevents.Client, secrets [][]byte) *Server {
return &Server{
client: client,
secrets: secrets,
clock: clockwork.NewRealClock(),
}
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := clog.FromContext(ctx)

// https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
payload, err := ValidatePayload(r, s.secrets)
if err != nil {
log.Errorf("failed to verify webhook: %v", err)
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, "failed to verify webhook: %v", err)
return
}

// https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers
t := github.WebHookType(r)
if t == "" {
log.Errorf("missing X-GitHub-Event header")
w.WriteHeader(http.StatusBadRequest)
return
}
t = "dev.chainguard.github." + t
log = log.With("event-type", t)

var msg struct {
Action string `json:"action"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
if err := json.Unmarshal(payload, &msg); err != nil {
log.Warnf("failed to unmarshal payload; action and subject will be unset: %v", err)
} else {
log = log.With("action", msg.Action, "repo", msg.Repository.FullName)
}

log.Debugf("forwarding event: %s", t)

event := cloudevents.NewEvent()
event.SetID(github.DeliveryID(r))
event.SetType(t)
event.SetSource(r.Host)
event.SetSubject(msg.Repository.FullName)
event.SetExtension("action", msg.Action)
if err := event.SetData(cloudevents.ApplicationJSON, eventData{
When: s.clock.Now(),
Body: payload,
}); err != nil {
log.Errorf("failed to set data: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}

const retryDelay = 10 * time.Millisecond
const maxRetry = 3
rctx := cloudevents.ContextWithRetriesExponentialBackoff(context.WithoutCancel(ctx), retryDelay, maxRetry)
if ceresult := s.client.Send(rctx, event); cloudevents.IsUndelivered(ceresult) || cloudevents.IsNACK(ceresult) {
log.Errorf("Failed to deliver event: %v", ceresult)
w.WriteHeader(http.StatusInternalServerError)
}
log.Debugf("event forwarded")
}

type eventData struct {
When time.Time `json:"when"`
Body json.RawMessage `json:"body"`
}

// ValidatePayload validates the payload of a webhook request for a given set of secrets.
// If any of the secrets are valid, the payload is returned with no error.
func ValidatePayload(r *http.Request, secrets [][]byte) ([]byte, error) {
// Largely forked from github.ValidatePayload - we can't use this directly to avoid consuming the body.
signature := r.Header.Get(github.SHA256SignatureHeader)
if signature == "" {
signature = r.Header.Get(github.SHA1SignatureHeader)
}
contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
return nil, err
}

body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}

for _, secret := range secrets {
payload, err := github.ValidatePayloadFromBody(contentType, bytes.NewBuffer(body), signature, secret)
if err == nil {
return payload, nil
}
}
return nil, fmt.Errorf("failed to validate payload")
}
Loading

0 comments on commit a141602

Please sign in to comment.