Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

feat(api): add GitHub webhook receiver for evented refresh #1317

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions apps/server/main.go
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -227,18 +227,19 @@ func run() error {
}

server := api.NewServer(api.ServerConfig{
BindAddr: *bind,
Port: *port,
CORSOrigins: parseCSV(*corsOrigins),
APIPrefixes: prefixes,
StaticFS: staticFS,
Registry: reg,
Auth: authCfg,
ReadOnly: *readOnly,
RepoStore: repoStore,
ReposRoot: absReposRoot,
RepoSyncTokens: appProvider,
SyncInterval: *syncInterval,
BindAddr: *bind,
Port: *port,
CORSOrigins: parseCSV(*corsOrigins),
APIPrefixes: prefixes,
StaticFS: staticFS,
Registry: reg,
Auth: authCfg,
ReadOnly: *readOnly,
RepoStore: repoStore,
ReposRoot: absReposRoot,
RepoSyncTokens: appProvider,
SyncInterval: *syncInterval,
GitHubWebhookSecret: strings.TrimSpace(os.Getenv("STACKIT_GITHUB_WEBHOOK_SECRET")),
})

errCh := make(chan error, 1)
Expand Down
103 changes: 103 additions & 0 deletions internal/api/handlers/webhook.go
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package handlers

import (
"context"
"io"
"log/slog"
"net/http"
"time"

"github.com/getstackit/stackit/internal/api/githubwebhook"
)

// maxWebhookBody caps the request body the webhook receiver will read. GitHub
// push payloads are well under this; the limit stops a forged or malformed
// request from forcing an unbounded read before the signature is even checked.
const maxWebhookBody = 1 << 20 // 1 MiB

// webhookSyncTimeout bounds the background fetch+refresh kicked off by a
// delivery, so a stuck remote can't leak goroutines.
const webhookSyncTimeout = 2 * time.Minute

// RepoSyncer mirror-fetches and refreshes a single managed repo by its GitHub
// coordinates. The concrete implementation is *reposync.Syncer; the handler
// takes the narrow interface so handlers stays decoupled from reposync.
type RepoSyncer interface {
SyncRepo(ctx context.Context, owner, name string) error
}

// WebhookHandler receives GitHub webhook deliveries at
// POST /api/v1/webhooks/github and turns a push into a refresh of the matching
// managed checkout. It is the low-latency complement to the interval sync loop;
// the loop remains as a backstop for missed deliveries and for metadata-ref
// pushes, which GitHub does not deliver push events for.
//
// The route bypasses the session/CSRF gate (GitHub can't carry either) and is
// authenticated solely by the HMAC signature, so it must fail closed when no
// secret is configured β€” otherwise it would be an open refresh trigger.
type WebhookHandler struct {
secret string
syncer RepoSyncer
}

// NewWebhookHandler wires a webhook receiver. An empty secret or nil syncer
// leaves the endpoint disabled (it 404s), so it is safe to mount
// unconditionally and gate purely on configuration.
func NewWebhookHandler(secret string, syncer RepoSyncer) *WebhookHandler {
return &WebhookHandler{secret: secret, syncer: syncer}
}

func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Disabled when unconfigured: 404 so the endpoint is indistinguishable from
// a server that never had it, rather than advertising a misconfigured hook.
if h.secret == "" || h.syncer == nil {
http.NotFound(w, r)
return
}

body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBody))
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}

if !githubwebhook.Verify(h.secret, body, r.Header.Get(githubwebhook.SignatureHeader)) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}

switch r.Header.Get(githubwebhook.EventHeader) {
case "push":
h.handlePush(w, body)
case "ping":
// GitHub sends ping once when the hook is created; acknowledge it.
w.WriteHeader(http.StatusNoContent)
default:
// Event types we don't act on (the App should only subscribe to push,
// but be tolerant). Acknowledge so GitHub doesn't retry.
w.WriteHeader(http.StatusNoContent)
}
}

func (h *WebhookHandler) handlePush(w http.ResponseWriter, body []byte) {
owner, name, ok := githubwebhook.ParsePush(body)
if !ok {
// Verified but not a recognizable push payload: acknowledge so GitHub
// doesn't retry a delivery we can't act on.
w.WriteHeader(http.StatusNoContent)
return
}

// A mirror-fetch is slow and GitHub expects a prompt response, so ack
// immediately and run the sync in the background, detached from the request.
// The inbound rate limiter bounds how often this fans out; a follow-up adds
// per-repo coalescing so a burst of pushes collapses to one fetch.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), webhookSyncTimeout)
defer cancel()
if err := h.syncer.SyncRepo(ctx, owner, name); err != nil {
slog.Warn("webhook: sync failed", "owner", owner, "name", name, "error", err)
}
}()
w.WriteHeader(http.StatusAccepted)
}
132 changes: 132 additions & 0 deletions internal/api/handlers/webhook_test.go
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package handlers

import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/getstackit/stackit/internal/api/githubwebhook"
)

type fakeSyncer struct {
mu sync.Mutex
calls []string
done chan struct{}
}

func newFakeSyncer() *fakeSyncer {
return &fakeSyncer{done: make(chan struct{}, 1)}
}

func (f *fakeSyncer) SyncRepo(_ context.Context, owner, name string) error {
f.mu.Lock()
f.calls = append(f.calls, owner+"/"+name)
f.mu.Unlock()
select {
case f.done <- struct{}{}:
default:
}
return nil
}

func (f *fakeSyncer) called() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.calls...)
}

func sign(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}

const testSecret = "webhook-secret"

func pushBody() []byte {
return []byte(`{"repository":{"name":"widget","full_name":"octo/widget","owner":{"login":"octo"}}}`)
}

// webhookRequest builds a POST signed with testSecret for the given event
// type and body.
func webhookRequest(event string, body []byte) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/github", strings.NewReader(string(body)))
req.Header.Set(githubwebhook.EventHeader, event)
req.Header.Set(githubwebhook.SignatureHeader, sign(testSecret, body))
return req
}

func TestWebhookHandler_DisabledWithoutSecret(t *testing.T) {
t.Parallel()
h := NewWebhookHandler("", newFakeSyncer())
rr := httptest.NewRecorder()
h.ServeHTTP(rr, webhookRequest("push", pushBody()))
require.Equal(t, http.StatusNotFound, rr.Code, "an unconfigured receiver must look like it doesn't exist")
}

func TestWebhookHandler_RejectsBadSignature(t *testing.T) {
t.Parallel()
sy := newFakeSyncer()
h := NewWebhookHandler(testSecret, sy)

body := pushBody()
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/github", strings.NewReader(string(body)))
req.Header.Set(githubwebhook.EventHeader, "push")
req.Header.Set(githubwebhook.SignatureHeader, sign("wrong-secret", body))

rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
require.Equal(t, http.StatusUnauthorized, rr.Code)
require.Empty(t, sy.called(), "an unverified delivery must not trigger a sync")
}

func TestWebhookHandler_PushTriggersSync(t *testing.T) {
t.Parallel()
sy := newFakeSyncer()
h := NewWebhookHandler(testSecret, sy)

rr := httptest.NewRecorder()
h.ServeHTTP(rr, webhookRequest("push", pushBody()))
require.Equal(t, http.StatusAccepted, rr.Code)

// The sync runs in a background goroutine; wait for it to land.
select {
case <-sy.done:
case <-time.After(2 * time.Second):
t.Fatal("SyncRepo was not called for a verified push")
}
require.Equal(t, []string{"octo/widget"}, sy.called())
}

func TestWebhookHandler_PingAcknowledgedWithoutSync(t *testing.T) {
t.Parallel()
sy := newFakeSyncer()
h := NewWebhookHandler(testSecret, sy)

rr := httptest.NewRecorder()
h.ServeHTTP(rr, webhookRequest("ping", []byte(`{"zen":"hi"}`)))
require.Equal(t, http.StatusNoContent, rr.Code)
require.Empty(t, sy.called(), "ping must not trigger a sync")
}

func TestWebhookHandler_UnparseablePushAcknowledged(t *testing.T) {
t.Parallel()
sy := newFakeSyncer()
h := NewWebhookHandler(testSecret, sy)

body := []byte(`{"zen":"no repository here"}`)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, webhookRequest("push", body))
// Verified but unactionable: ack so GitHub stops retrying, no sync.
require.Equal(t, http.StatusNoContent, rr.Code)
require.Empty(t, sy.called())
}
69 changes: 49 additions & 20 deletions internal/api/server.go
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,16 @@ type ServerConfig struct {

// SyncInterval, when > 0, enables the background sync loop: every interval
// each managed repo is mirror-fetched from its remote so the served state
// stays current without a local actor. Zero disables it.
// stays current without a local actor. Zero disables it. Even with the loop
// off, the syncer still backs on-demand refreshes (webhook receiver).
SyncInterval time.Duration

// GitHubWebhookSecret is the shared secret GitHub signs webhook deliveries
// with. When set, POST /api/v1/webhooks/github accepts verified push events
// and refreshes the matching managed repo immediately (the interval loop
// stays as a backstop). Empty disables the endpoint (it 404s), which is the
// correct posture for local/dev servers GitHub can't reach.
GitHubWebhookSecret string
}

// AuthConfig is the runtime auth setup. SessionStore must outlive the
Expand All @@ -105,11 +113,28 @@ type Server struct {
config ServerConfig
httpServer *http.Server
syncCancel context.CancelFunc

// syncer is the shared per-repo mirror-fetch+refresh engine. Both the
// interval loop (when enabled) and the webhook receiver drive it, so it is
// built once here regardless of whether the loop runs.
syncer *reposync.Syncer
}

// NewServer creates a new API server backed by the given registry.
func NewServer(cfg ServerConfig) *Server {
return &Server{config: cfg}
s := &Server{config: cfg}

// Guard against the typed-nil interface trap: assigning a nil
// *AppTokenProvider to the interface yields a non-nil interface wrapping a
// nil pointer. Only set provider when a concrete one exists; otherwise the
// syncer fetches unauthenticated (public repos only).
var provider reposync.TokenProvider
if cfg.RepoSyncTokens != nil {
provider = cfg.RepoSyncTokens
}
s.syncer = reposync.New(cfg.Registry, provider, cfg.SyncInterval)

return s
}

// Start begins serving HTTP requests. It blocks until the server is stopped.
Expand Down Expand Up @@ -145,17 +170,13 @@ func (s *Server) startSyncLoop() {
if s.config.SyncInterval <= 0 {
return
}
var provider reposync.TokenProvider
if s.config.RepoSyncTokens != nil {
provider = s.config.RepoSyncTokens
} else {
if s.config.RepoSyncTokens == nil {
slog.Warn("repo sync: no GitHub App configured; only public repos will refresh")
}

ctx, cancel := context.WithCancel(context.Background())
s.syncCancel = cancel
syncer := reposync.New(s.config.Registry, provider, s.config.SyncInterval)
go syncer.Run(ctx)
go s.syncer.Run(ctx)
slog.Info("repo sync loop enabled", "interval", s.config.SyncInterval.String())
}

Expand Down Expand Up @@ -276,20 +297,25 @@ func (s *Server) buildHandler() (http.Handler, error) {
// read API is unaffected.
sessionGated = auth.RequireCSRFHeader(sessionGated)

// /config advertises server capabilities and must be reachable without a
// session so the client can learn whether a login is required before
// attempting one. It bypasses the session gate but still gets the outer
// middleware (and rate limiting below).
// Public, unauthenticated API routes that bypass the session/CSRF gate but
// still get the outer middleware and rate limiting below:
// - /config advertises capabilities so the client can learn whether a
// login is required before attempting one.
// - /webhooks/github receives GitHub push deliveries (authenticated by
// HMAC signature, not a session) and refreshes the matching managed
// repo. It self-disables (404) when no webhook secret is configured.
webhookHandler := handlers.NewWebhookHandler(s.config.GitHubWebhookSecret, s.syncer)
publicAPIMux := http.NewServeMux()
for _, prefix := range prefixes {
publicAPIMux.Handle("GET "+prefix+"/config", configHandler)
publicAPIMux.Handle("POST "+prefix+"/webhooks/github", webhookHandler)
}

// Combined API dispatch: public capability routes bypass the session
// gate; everything else is session-gated. Rate limiting wraps both so the
// public endpoint is protected from floods too.
// Combined API dispatch: public routes bypass the session gate; everything
// else is session-gated. Rate limiting wraps both so the public endpoints
// are protected from floods too.
var protectedAPI http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isConfigPath(r.URL.Path, prefixes) {
if isPublicAPIPath(r.URL.Path, prefixes) {
publicAPIMux.ServeHTTP(w, r)
return
}
Expand Down Expand Up @@ -415,11 +441,14 @@ func isAPIPath(path string, prefixes []string) bool {
return false
}

// isConfigPath reports whether path is the unauthenticated capabilities
// endpoint for any configured prefix (e.g. /api/v1/config or /api/config).
func isConfigPath(path string, prefixes []string) bool {
// isPublicAPIPath reports whether path is one of the unauthenticated API
// endpoints that bypass the session/CSRF gate (but stay rate-limited): the
// capabilities endpoint (/config) and the GitHub webhook receiver
// (/webhooks/github), for any configured prefix.
func isPublicAPIPath(path string, prefixes []string) bool {
for _, prefix := range prefixes {
if path == prefix+"/config" {
switch path {
case prefix + "/config", prefix + "/webhooks/github":
return true
}
}
Expand Down
Loading
Loading

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /