Skip to content
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
24 changes: 20 additions & 4 deletions cmd/ephemerd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<provider> 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)
Expand Down
46 changes: 37 additions & 9 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")

Expand Down