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", "")