diff --git a/apps/server/main.go b/apps/server/main.go index ca844599..2a8abd73 100644 --- a/apps/server/main.go +++ b/apps/server/main.go @@ -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) diff --git a/internal/api/handlers/webhook.go b/internal/api/handlers/webhook.go new file mode 100644 index 00000000..b939e5e0 --- /dev/null +++ b/internal/api/handlers/webhook.go @@ -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) +} diff --git a/internal/api/handlers/webhook_test.go b/internal/api/handlers/webhook_test.go new file mode 100644 index 00000000..6d295b14 --- /dev/null +++ b/internal/api/handlers/webhook_test.go @@ -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()) +} diff --git a/internal/api/server.go b/internal/api/server.go index 80fab5b7..6eec9c4a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 @@ -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. @@ -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()) } @@ -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 } @@ -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 } } diff --git a/internal/api/webhook_route_test.go b/internal/api/webhook_route_test.go new file mode 100644 index 00000000..0f76cc69 --- /dev/null +++ b/internal/api/webhook_route_test.go @@ -0,0 +1,69 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getstackit/stackit/internal/api/registry" +) + +func signBody(secret, body string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(body)) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +// TestWebhookRouteReachableWithoutSession proves the webhook bypasses the +// session/CSRF gate (GitHub carries neither) yet is authenticated by signature: +// a correctly signed push is accepted even with auth configured. +func TestWebhookRouteReachableWithoutSession(t *testing.T) { + t.Parallel() + const secret = "route-secret" + srv := NewServer(ServerConfig{ + APIPrefixes: []string{"/api/v1"}, + Registry: registry.New(), + Auth: newTestAuthConfig(t), + GitHubWebhookSecret: secret, + }) + handler, err := srv.buildHandler() + require.NoError(t, err) + + body := `{"repository":{"name":"widget","full_name":"octo/widget","owner":{"login":"octo"}}}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/github", strings.NewReader(body)) + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("X-Hub-Signature-256", signBody(secret, body)) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // 202 Accepted: routed past session+CSRF and verified. The repo isn't + // registered, but that failure happens in the detached background sync, not + // on the response path. + require.Equal(t, http.StatusAccepted, rr.Code) +} + +// TestWebhookRouteDisabledWithoutSecret proves the endpoint self-disables when +// no secret is configured, the correct posture for a local server. +func TestWebhookRouteDisabledWithoutSecret(t *testing.T) { + t.Parallel() + srv := NewServer(ServerConfig{ + APIPrefixes: []string{"/api/v1"}, + Registry: registry.New(), + }) + handler, err := srv.buildHandler() + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/github", strings.NewReader("{}")) + req.Header.Set("X-GitHub-Event", "push") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusNotFound, rr.Code) +}