From 4ffa92365cb2554b2235e92447f1e2c1d810d4d1 Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Tue, 7 Jul 2026 21:29:26 -0700 Subject: [PATCH] feat(webhook): add tunnel = "external" for externally-managed ingress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a webhook tunnel mode for when a tunnel already exists but is managed OUTSIDE ephemerd — e.g. a Cloudflare tunnel on another host forwarding a public hostname to ephemerd's webhook port. In this mode ephemerd serves the /webhook/ receiver and disables polling, but does NOT create a tunnel or auto-register the GitHub webhook (that ingress and registration are owned externally). A matching secret is required so incoming signatures can be verified — unlike managed tunnels (ngrok/localtunnel) we must not invent one. Also fixes a misleading log line: with a webhook secret set but tunnel = "none", ephemerd already ran in webhook mode (polling disabled) yet logged "polling mode enabled (tunnel disabled)". The startup log now reflects the real discovery mode for each tunnel setting, and the webhook port defaults to 8080 whenever a receiver is active. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ChVRC9ZrvQarrctDwSy1S5 --- cmd/ephemerd/main.go | 24 +++++++++++--- pkg/config/config.go | 46 +++++++++++++++++++++----- pkg/config/config_test.go | 69 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 13 deletions(-) diff --git a/cmd/ephemerd/main.go b/cmd/ephemerd/main.go index 48a578e..7f4d921 100644 --- a/cmd/ephemerd/main.go +++ b/cmd/ephemerd/main.go @@ -551,17 +551,33 @@ func serve(ctx context.Context, configFile, imagesDirFlag string, containerdTCPP log.Info("DinD enabled — containers will have /var/run/docker.sock") } - // Set up webhook tunnel if configured + // Set up webhook tunnel if configured. var tunnelProvider tunnel.Provider - if cfg.Webhook.Tunnel != "none" { + switch cfg.Webhook.Tunnel { + case "none": + // No ephemerd-managed tunnel. If a webhook secret is set the scheduler + // still serves the /webhook/ receiver (ingress arrives some + // other way) and disables polling; otherwise it falls back to polling. + if cfg.Webhook.Secret != "" { + log.Info("webhook mode enabled (external ingress, no managed tunnel)", "port", cfg.Webhook.Port) + } else { + log.Info("polling mode enabled (no tunnel, no webhook secret)") + } + case "external": + // A tunnel exists but is managed OUTSIDE ephemerd — e.g. a Cloudflare + // tunnel running on another host that forwards a public hostname to + // this port. ephemerd serves the webhook receiver and disables polling, + // but never creates a tunnel or auto-registers webhooks: that ingress + // and the GitHub webhook registration are owned externally. Requires a + // secret (validated in config) so signatures can be verified. + log.Info("webhook mode enabled (external tunnel, ingress managed outside ephemerd)", "port", cfg.Webhook.Port) + default: var err error tunnelProvider, err = tunnel.New(cfg.Webhook.Tunnel, cfg.Webhook.NgrokAuthtoken, cfg.Webhook.TunnelURL) if err != nil { return fmt.Errorf("creating tunnel provider: %w", err) } log.Info("webhook tunnel configured", "provider", cfg.Webhook.Tunnel) - } else { - log.Info("polling mode enabled (tunnel disabled)") } // Start scheduler (ties CI provider jobs to container lifecycle) diff --git a/pkg/config/config.go b/pkg/config/config.go index 84e2e36..824b6ae 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -98,13 +98,19 @@ func (m MetricsConfig) ParsedContainerStatsInterval() time.Duration { // WebhookConfig configures webhook delivery and tunnel providers. // By default, ephemerd uses polling (tunnel = "none"). -// Set tunnel = "localtunnel" or "ngrok" for instant webhook delivery. +// Set tunnel = "localtunnel" or "ngrok" for ephemerd to create and manage a +// tunnel and auto-register the GitHub webhook. Set tunnel = "external" when a +// tunnel is provided by something else (e.g. a Cloudflare tunnel on another +// host that forwards a public hostname to this port): ephemerd then serves the +// webhook receiver and disables polling, but does not create a tunnel or +// register the webhook — that is owned externally, so a matching secret is +// required. type WebhookConfig struct { - Secret string `toml:"secret"` // webhook HMAC secret (auto-generated if empty) + Secret string `toml:"secret"` // webhook HMAC secret (auto-generated for managed tunnels; required for "external") Port int `toml:"port"` // listen port for health endpoint (default 8080) TLSCert string `toml:"tls_cert"` // TLS certificate path (direct TLS, no tunnel) TLSKey string `toml:"tls_key"` // TLS private key path - Tunnel string `toml:"tunnel"` // "none" (default, polling), "localtunnel", or "ngrok" + Tunnel string `toml:"tunnel"` // "none" (default, polling), "external" (unmanaged ingress), "localtunnel", or "ngrok" TunnelURL string `toml:"tunnel_url"` // localtunnel: self-hosted server URL NgrokAuthtoken string `toml:"ngrok_authtoken"` // ngrok auth token (or use NGROK_AUTHTOKEN env) TunnelMaxRetries int `toml:"tunnel_max_retries"` // max consecutive reconnect failures before falling back to polling (default 5) @@ -721,13 +727,35 @@ func (c *Config) validate() error { } } - // Generate a random webhook secret if not explicitly set and tunnel is active - if c.Webhook.Secret == "" && c.Webhook.Tunnel != "none" { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return fmt.Errorf("generating webhook secret: %w", err) + // Webhook secret handling depends on who owns the tunnel: + // - "external": ingress and the GitHub webhook are configured elsewhere, + // so the secret must match that external config — we cannot invent one. + // - managed tunnels ("localtunnel"/"ngrok"): ephemerd registers the + // webhook itself, so a random secret is fine when none is provided. + // - "none": polling (or external ingress with an explicit secret); nothing + // to generate. + switch c.Webhook.Tunnel { + case "external": + if c.Webhook.Secret == "" { + return fmt.Errorf(`webhook.secret is required when webhook.tunnel = "external" (it must match the secret configured on the external GitHub webhook)`) } - c.Webhook.Secret = hex.EncodeToString(b) + case "none": + // Nothing to generate; secret, if set, enables an externally-fronted + // webhook receiver, otherwise ephemerd polls. + default: + if c.Webhook.Secret == "" { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return fmt.Errorf("generating webhook secret: %w", err) + } + c.Webhook.Secret = hex.EncodeToString(b) + } + } + + // Any webhook mode (managed tunnel, or external/none with a secret) needs a + // listen port; default to 8080 when unset. + if c.Webhook.Port == 0 && (c.Webhook.Tunnel != "none" || c.Webhook.Secret != "") { + c.Webhook.Port = 8080 } return nil diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index b012deb..f92c8fc 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -178,6 +178,75 @@ func TestValidate_AcceptsEnvToken(t *testing.T) { } } +func TestValidate_ExternalTunnel_RequiresSecret(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "ghp_x") + + cfg := &Config{ + GitHub: GitHubConfig{Owner: "org"}, + Webhook: WebhookConfig{Tunnel: "external"}, + } + if err := cfg.validate(); err == nil { + t.Fatal(`expected error: tunnel="external" without a secret must be rejected`) + } +} + +func TestValidate_ExternalTunnel_KeepsSecretAndDefaultsPort(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "ghp_x") + + const secret = "deadbeefcafe" + cfg := &Config{ + GitHub: GitHubConfig{Owner: "org"}, + Webhook: WebhookConfig{Tunnel: "external", Secret: secret}, + } + if err := cfg.validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // External ingress: the secret must match the external webhook config, so + // it must never be regenerated. + if cfg.Webhook.Secret != secret { + t.Errorf("Secret = %q, want it left untouched (%q)", cfg.Webhook.Secret, secret) + } + if cfg.Webhook.Port != 8080 { + t.Errorf("Port = %d, want default 8080", cfg.Webhook.Port) + } +} + +func TestValidate_ManagedTunnel_GeneratesSecret(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "ghp_x") + + cfg := &Config{ + GitHub: GitHubConfig{Owner: "org"}, + Webhook: WebhookConfig{Tunnel: "ngrok"}, + } + if err := cfg.validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Managed tunnels register the webhook themselves, so a generated secret is + // fine (and expected) when none was provided. + if cfg.Webhook.Secret == "" { + t.Error("expected a generated secret for a managed tunnel, got empty") + } +} + +func TestValidate_NoneWithSecret_DefaultsPort(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "ghp_x") + + const secret = "abc123" + cfg := &Config{ + GitHub: GitHubConfig{Owner: "org"}, + Webhook: WebhookConfig{Tunnel: "none", Secret: secret}, + } + if err := cfg.validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Webhook.Secret != secret { + t.Errorf("Secret = %q, want %q (must not be regenerated)", cfg.Webhook.Secret, secret) + } + if cfg.Webhook.Port != 8080 { + t.Errorf("Port = %d, want default 8080 when a secret enables the receiver", cfg.Webhook.Port) + } +} + func TestValidate_AppID_RequiresInstallationID(t *testing.T) { t.Setenv("GITHUB_TOKEN", "")