diff --git a/cmd/main.go b/cmd/main.go index 4777665d..b8750b2b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -51,7 +51,6 @@ type ImageUpdaterConfig struct { GitCommitSignOff bool DisableKubeEvents bool GitCreds git.CredsStore - WebhookPort int EnableWebhook bool } diff --git a/cmd/run.go b/cmd/run.go index a2ae4ed3..a90b4635 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -32,6 +32,7 @@ import ( // newRunCommand implements "run" command func newRunCommand() *cobra.Command { var cfg *ImageUpdaterConfig = &ImageUpdaterConfig{} + var webhookCfg *WebhookConfig = &WebhookConfig{} var once bool var kubeConfig string var disableKubernetes bool @@ -183,7 +184,7 @@ func newRunCommand() *cobra.Command { // Start the webhook server if enabled var webhookServer *webhook.WebhookServer - if cfg.EnableWebhook && cfg.WebhookPort > 0 { + if cfg.EnableWebhook && webhookCfg.Port > 0 { // Initialize the ArgoCD client for webhook server var argoClient argocd.ArgoCD switch cfg.ApplicationsAPIKind { @@ -200,30 +201,36 @@ func newRunCommand() *cobra.Command { handler := webhook.NewWebhookHandler() // Register supported webhook handlers with default empty secrets - // In production, these would be configured via flags or environment variables - dockerHandler := webhook.NewDockerHubWebhook("") + dockerHandler := webhook.NewDockerHubWebhook(webhookCfg.DockerSecret) handler.RegisterHandler(dockerHandler) - ghcrHandler := webhook.NewGHCRWebhook("") + ghcrHandler := webhook.NewGHCRWebhook(webhookCfg.GHCRSecret) handler.RegisterHandler(ghcrHandler) - harborHandler := webhook.NewHarborWebhook("") + harborHandler := webhook.NewHarborWebhook(webhookCfg.HarborSecret) handler.RegisterHandler(harborHandler) - log.Infof("Starting webhook server on port %d", cfg.WebhookPort) - webhookServer = webhook.NewWebhookServer(cfg.WebhookPort, handler, cfg.KubeClient, argoClient) + quayHandler := webhook.NewQuayWebhook(webhookCfg.QuaySecret) + handler.RegisterHandler(quayHandler) + + log.Infof("Starting webhook server on port %d", webhookCfg.Port) + webhookServer = webhook.NewWebhookServer(webhookCfg.Port, handler, cfg.KubeClient, argoClient) // Set updater config - updaterConfig := &argocd.UpdaterConfig{ + webhookServer.UpdaterConfig = &argocd.UpdateConfiguration{ + NewRegFN: registry.NewClient, + ArgoClient: cfg.ArgoClient, + KubeClient: cfg.KubeClient, DryRun: cfg.DryRun, GitCommitUser: cfg.GitCommitUser, GitCommitEmail: cfg.GitCommitMail, - GitCommitMessage: cfg.GitCommitMessage.Tree.Root.String(), + GitCommitMessage: cfg.GitCommitMessage, GitCommitSigningKey: cfg.GitCommitSigningKey, GitCommitSigningMethod: cfg.GitCommitSigningMethod, GitCommitSignOff: cfg.GitCommitSignOff, + DisableKubeEvents: cfg.DisableKubeEvents, + GitCreds: cfg.GitCreds, } - webhookServer.UpdaterConfig = updaterConfig whErrCh = make(chan error, 1) go func() { @@ -233,7 +240,7 @@ func newRunCommand() *cobra.Command { } }() - log.Infof("Webhook server started and listening on port %d", cfg.WebhookPort) + log.Infof("Webhook server started and listening on port %d", webhookCfg.Port) } // This is our main loop. We leave it only when our health probe server @@ -323,9 +330,14 @@ func newRunCommand() *cobra.Command { runCmd.Flags().BoolVar(&cfg.GitCommitSignOff, "git-commit-sign-off", env.GetBoolVal("GIT_COMMIT_SIGN_OFF", false), "Whether to sign-off git commits") runCmd.Flags().StringVar(&commitMessagePath, "git-commit-message-path", defaultCommitTemplatePath, "Path to a template to use for Git commit messages") runCmd.Flags().BoolVar(&cfg.DisableKubeEvents, "disable-kube-events", env.GetBoolVal("IMAGE_UPDATER_KUBE_EVENTS", false), "Disable kubernetes events") - runCmd.Flags().IntVar(&cfg.WebhookPort, "webhook-port", env.ParseNumFromEnv("WEBHOOK_PORT", 8082, 0, 65535), "Port to start the webhook server on, 0 to disable") runCmd.Flags().BoolVar(&cfg.EnableWebhook, "enable-webhook", env.GetBoolVal("ENABLE_WEBHOOK", false), "Enable webhook server for receiving registry events") + runCmd.Flags().IntVar(&webhookCfg.Port, "webhook-port", env.ParseNumFromEnv("WEBHOOK_PORT", 8082, 0, 65535), "Port to listen on for webhook events") + runCmd.Flags().StringVar(&webhookCfg.DockerSecret, "docker-webhook-secret", env.GetStringVal("DOCKER_WEBHOOK_SECRET", ""), "Secret for validating Docker Hub webhooks") + runCmd.Flags().StringVar(&webhookCfg.GHCRSecret, "ghcr-webhook-secret", env.GetStringVal("GHCR_WEBHOOK_SECRET", ""), "Secret for validating GitHub Container Registry webhooks") + runCmd.Flags().StringVar(&webhookCfg.QuaySecret, "quay-webhook-secret", env.GetStringVal("QUAY_WEBHOOK_SECRET", ""), "Secret for validating Quay webhooks") + runCmd.Flags().StringVar(&webhookCfg.HarborSecret, "harbor-webhook-secret", env.GetStringVal("HARBOR_WEBHOOK_SECRET", ""), "Secret for validating Harbor webhooks") + return runCmd } diff --git a/cmd/webhook.go b/cmd/webhook.go index b23df1f4..dd6317c4 100644 --- a/cmd/webhook.go +++ b/cmd/webhook.go @@ -2,37 +2,46 @@ package main import ( "context" + "errors" + "fmt" "os" "os/signal" + "strconv" + "strings" "syscall" + "text/template" + "time" "github.com/argoproj-labs/argocd-image-updater/pkg/argocd" - "github.com/argoproj-labs/argocd-image-updater/pkg/kube" + "github.com/argoproj-labs/argocd-image-updater/pkg/common" + "github.com/argoproj-labs/argocd-image-updater/pkg/version" "github.com/argoproj-labs/argocd-image-updater/pkg/webhook" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/env" "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log" + "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/registry" + "github.com/argoproj/argo-cd/v2/util/askpass" "github.com/spf13/cobra" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// WebhookOptions holds the options for the webhook server -type WebhookOptions struct { - Port int - DockerSecret string - GHCRSecret string - UpdateOnEvent bool - ApplicationsAPIKind string - AppNamespace string - ServerAddr string - Insecure bool - Plaintext bool - GRPCWeb bool - AuthToken string +// WebhookConfig holds the options for the webhook server +type WebhookConfig struct { + Port int + DockerSecret string + GHCRSecret string + QuaySecret string + HarborSecret string } -var webhookOpts WebhookOptions - // NewWebhookCommand creates a new webhook command func NewWebhookCommand() *cobra.Command { + var cfg *ImageUpdaterConfig = &ImageUpdaterConfig{} + var webhookCfg *WebhookConfig = &WebhookConfig{} + var kubeConfig string + var disableKubernetes bool + var commitMessagePath string + var commitMessageTpl string var webhookCmd = &cobra.Command{ Use: "webhook", Short: "Start webhook server to receive registry events", @@ -44,68 +53,171 @@ update check for the affected images. Supported registries: - Docker Hub - GitHub Container Registry (GHCR) +- Quay +- Harbor `, - Run: func(cmd *cobra.Command, args []string) { - runWebhook() + RunE: func(cmd *cobra.Command, args []string) error { + if err := log.SetLogLevel(cfg.LogLevel); err != nil { + return err + } + + if cfg.MaxConcurrency < 1 { + return fmt.Errorf("--max-concurrency must be greater than 1") + } + + log.Infof("%s %s starting [loglevel:%s, webhookport:%s]", + version.BinaryName(), + version.Version(), + strings.ToUpper(cfg.LogLevel), + strconv.Itoa(webhookCfg.Port), + ) + + if commitMessagePath != "" { + tpl, err := os.ReadFile(commitMessagePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + log.Warnf("commit message template at %s does not exist, using default", commitMessagePath) + commitMessageTpl = common.DefaultGitCommitMessage + } else { + log.Fatalf("could not read commit message template: %v", err) + } + } else { + commitMessageTpl = string(tpl) + } + } + + if commitMessageTpl == "" { + log.Infof("Using default Git commit messages") + commitMessageTpl = common.DefaultGitCommitMessage + } + + if tpl, err := template.New("commitMessage").Parse(commitMessageTpl); err != nil { + log.Fatalf("could not parse commit message template: %v", err) + } else { + log.Debugf("Successfully parsed commit messege template") + cfg.GitCommitMessage = tpl + } + + if cfg.RegistriesConf != "" { + st, err := os.Stat(cfg.RegistriesConf) + if err != nil || st.IsDir() { + log.Warnf("Registry configuration at %s could not be read: %v -- using default configuration", cfg.RegistriesConf, err) + } else { + err = registry.LoadRegistryConfiguration(cfg.RegistriesConf, false) + if err != nil { + log.Errorf("Could not load registry configuration from %s: %v", cfg.RegistriesConf, err) + return nil + } + } + } + + var err error + if !disableKubernetes { + ctx := context.Background() + cfg.KubeClient, err = getKubeConfig(ctx, cfg.ArgocdNamespace, kubeConfig) + if err != nil { + log.Fatalf("could not create K8s client: %v", err) + } + if cfg.ClientOpts.ServerAddr == "" { + cfg.ClientOpts.ServerAddr = fmt.Sprintf("argocd-server.%s", cfg.KubeClient.KubeClient.Namespace) + } + } + if cfg.ClientOpts.ServerAddr == "" { + cfg.ClientOpts.ServerAddr = defaultArgoCDServerAddr + } + + if token := os.Getenv("ARGOCD_TOKEN"); token != "" && cfg.ClientOpts.AuthToken == "" { + log.Debugf("Using ArgoCD API credentials from environment ARGOCD_TOKEN") + cfg.ClientOpts.AuthToken = token + } + + log.Infof("ArgoCD configuration: [apiKind=%s, server=%s, auth_token=%v, insecure=%v, grpc_web=%v, plaintext=%v]", + cfg.ApplicationsAPIKind, + cfg.ClientOpts.ServerAddr, + cfg.ClientOpts.AuthToken != "", + cfg.ClientOpts.Insecure, + cfg.ClientOpts.GRPCWeb, + cfg.ClientOpts.Plaintext, + ) + + // Start up the credentials store server + cs := askpass.NewServer(askpass.SocketPath) + csErrCh := make(chan error) + go func() { + log.Debugf("Starting askpass server") + csErrCh <- cs.Run() + }() + + // Wait for cred server to be started, just in case + err = <-csErrCh + if err != nil { + log.Errorf("Error running askpass server: %v", err) + return err + } + + cfg.GitCreds = cs + + err = runWebhook(cfg, webhookCfg) + return err }, } - webhookCmd.Flags().IntVar(&webhookOpts.Port, "port", 8080, "Port to listen on for webhook events") - webhookCmd.Flags().StringVar(&webhookOpts.DockerSecret, "docker-secret", "", "Secret for validating Docker Hub webhooks") - webhookCmd.Flags().StringVar(&webhookOpts.GHCRSecret, "ghcr-secret", "", "Secret for validating GitHub Container Registry webhooks") - webhookCmd.Flags().BoolVar(&webhookOpts.UpdateOnEvent, "update-on-event", true, "Whether to trigger image update checks when webhook events are received") - webhookCmd.Flags().StringVar(&webhookOpts.ApplicationsAPIKind, "applications-api", applicationsAPIKindK8S, "API kind that is used to manage Argo CD applications ('kubernetes' or 'argocd')") - webhookCmd.Flags().StringVar(&webhookOpts.AppNamespace, "application-namespace", "", "namespace where Argo Image Updater will manage applications") - webhookCmd.Flags().StringVar(&webhookOpts.ServerAddr, "argocd-server-addr", "", "address of ArgoCD API server") - webhookCmd.Flags().BoolVar(&webhookOpts.Insecure, "argocd-insecure", false, "(INSECURE) ignore invalid TLS certs for ArgoCD server") - webhookCmd.Flags().BoolVar(&webhookOpts.Plaintext, "argocd-plaintext", false, "(INSECURE) connect without TLS to ArgoCD server") - webhookCmd.Flags().BoolVar(&webhookOpts.GRPCWeb, "argocd-grpc-web", false, "use grpc-web for connection to ArgoCD") - webhookCmd.Flags().StringVar(&webhookOpts.AuthToken, "argocd-auth-token", "", "use token for authenticating to ArgoCD") + // Set Image Updater flags + webhookCmd.Flags().StringVar(&cfg.ApplicationsAPIKind, "applications-api", env.GetStringVal("APPLICATIONS_API", applicationsAPIKindK8S), "API kind that is used to manage Argo CD applications ('kubernetes' or 'argocd')") + webhookCmd.Flags().StringVar(&cfg.ClientOpts.ServerAddr, "argocd-server-addr", env.GetStringVal("ARGOCD_SERVER", ""), "address of ArgoCD API server") + webhookCmd.Flags().BoolVar(&cfg.ClientOpts.GRPCWeb, "argocd-grpc-web", env.GetBoolVal("ARGOCD_GRPC_WEB", false), "use grpc-web for connection to ArgoCD") + webhookCmd.Flags().BoolVar(&cfg.ClientOpts.Insecure, "argocd-insecure", env.GetBoolVal("ARGOCD_INSECURE", false), "(INSECURE) ignore invalid TLS certs for ArgoCD server") + webhookCmd.Flags().BoolVar(&cfg.ClientOpts.Plaintext, "argocd-plaintext", env.GetBoolVal("ARGOCD_PLAINTEXT", false), "(INSECURE) connect without TLS to ArgoCD server") + webhookCmd.Flags().StringVar(&cfg.ClientOpts.AuthToken, "argocd-auth-token", "", "use token for authenticating to ArgoCD (unsafe - consider setting ARGOCD_TOKEN env var instead)") + webhookCmd.Flags().BoolVar(&cfg.DryRun, "dry-run", false, "run in dry-run mode. If set to true, do not perform any changes") + webhookCmd.Flags().DurationVar(&cfg.CheckInterval, "interval", env.GetDurationVal("IMAGE_UPDATER_INTERVAL", 2*time.Minute), "interval for how often to check for updates") + webhookCmd.Flags().StringVar(&cfg.LogLevel, "loglevel", env.GetStringVal("IMAGE_UPDATER_LOGLEVEL", "info"), "set the loglevel to one of trace|debug|info|warn|error") + webhookCmd.Flags().StringVar(&kubeConfig, "kubeconfig", "", "full path to kubernetes client configuration, i.e. ~/.kube/config") + webhookCmd.Flags().IntVar(&cfg.HealthPort, "health-port", 8080, "port to start the health server on, 0 to disable") + webhookCmd.Flags().IntVar(&cfg.MetricsPort, "metrics-port", 8081, "port to start the metrics server on, 0 to disable") + webhookCmd.Flags().StringVar(&cfg.RegistriesConf, "registries-conf-path", defaultRegistriesConfPath, "path to registries configuration file") + webhookCmd.Flags().BoolVar(&disableKubernetes, "disable-kubernetes", false, "do not create and use a Kubernetes client") + webhookCmd.Flags().IntVar(&cfg.MaxConcurrency, "max-concurrency", 10, "maximum number of update threads to run concurrently") + webhookCmd.Flags().StringVar(&cfg.ArgocdNamespace, "argocd-namespace", "", "namespace where ArgoCD runs in (current namespace by default)") + webhookCmd.Flags().StringVar(&cfg.AppNamespace, "application-namespace", v1.NamespaceAll, "namespace where Argo Image Updater will manage applications (all namespaces by default)") + webhookCmd.Flags().StringSliceVar(&cfg.AppNamePatterns, "match-application-name", nil, "patterns to match application name against") + webhookCmd.Flags().StringVar(&cfg.AppLabel, "match-application-label", "", "label selector to match application labels against") + webhookCmd.Flags().StringVar(&cfg.GitCommitUser, "git-commit-user", env.GetStringVal("GIT_COMMIT_USER", "argocd-image-updater"), "Username to use for Git commits") + webhookCmd.Flags().StringVar(&cfg.GitCommitMail, "git-commit-email", env.GetStringVal("GIT_COMMIT_EMAIL", "noreply@argoproj.io"), "E-Mail address to use for Git commits") + webhookCmd.Flags().StringVar(&cfg.GitCommitSigningKey, "git-commit-signing-key", env.GetStringVal("GIT_COMMIT_SIGNING_KEY", ""), "GnuPG key ID or path to Private SSH Key used to sign the commits") + webhookCmd.Flags().StringVar(&cfg.GitCommitSigningMethod, "git-commit-signing-method", env.GetStringVal("GIT_COMMIT_SIGNING_METHOD", "openpgp"), "Method used to sign Git commits ('openpgp' or 'ssh')") + webhookCmd.Flags().BoolVar(&cfg.GitCommitSignOff, "git-commit-sign-off", env.GetBoolVal("GIT_COMMIT_SIGN_OFF", false), "Whether to sign-off git commits") + webhookCmd.Flags().StringVar(&commitMessagePath, "git-commit-message-path", defaultCommitTemplatePath, "Path to a template to use for Git commit messages") + webhookCmd.Flags().BoolVar(&cfg.DisableKubeEvents, "disable-kube-events", env.GetBoolVal("IMAGE_UPDATER_KUBE_EVENTS", false), "Disable kubernetes events") + + webhookCmd.Flags().IntVar(&webhookCfg.Port, "webhook-port", env.ParseNumFromEnv("WEBHOOK_PORT", 8082, 0, 65535), "Port to listen on for webhook events") + webhookCmd.Flags().StringVar(&webhookCfg.DockerSecret, "docker-webhook-secret", env.GetStringVal("DOCKER_WEBHOOK_SECRET", ""), "Secret for validating Docker Hub webhooks") + webhookCmd.Flags().StringVar(&webhookCfg.GHCRSecret, "ghcr-webhook-secret", env.GetStringVal("GHCR_WEBHOOK_SECRET", ""), "Secret for validating GitHub Container Registry webhooks") + webhookCmd.Flags().StringVar(&webhookCfg.QuaySecret, "quay-webhook-secret", env.GetStringVal("QUAY_WEBHOOK_SECRET", ""), "Secret for validating Quay webhooks") + webhookCmd.Flags().StringVar(&webhookCfg.HarborSecret, "harbor-webhook-secret", env.GetStringVal("HARBOR_WEBHOOK_SECRET", ""), "Secret for validating Harbor webhooks") return webhookCmd } // runWebhook starts the webhook server -func runWebhook() { - log.Infof("Starting webhook server on port %d", webhookOpts.Port) +func runWebhook(cfg *ImageUpdaterConfig, webhookCfg *WebhookConfig) error { + log.Infof("Starting webhook server on port %d", webhookCfg.Port) // Initialize the ArgoCD client - var argoClient argocd.ArgoCD var err error // Create Kubernetes client - var kubeClient *kube.ImageUpdaterKubernetesClient - kubeClient, err = getKubeConfig(context.TODO(), "", "") + cfg.KubeClient, err = getKubeConfig(context.TODO(), "", "") if err != nil { log.Fatalf("Could not create Kubernetes client: %v", err) + return err } // Set up based on application API kind - if webhookOpts.ApplicationsAPIKind == applicationsAPIKindK8S { - argoClient, err = argocd.NewK8SClient(kubeClient, &argocd.K8SClientOptions{AppNamespace: webhookOpts.AppNamespace}) + if cfg.ApplicationsAPIKind == applicationsAPIKindK8S { + cfg.ArgoClient, err = argocd.NewK8SClient(cfg.KubeClient, &argocd.K8SClientOptions{AppNamespace: cfg.AppNamespace}) } else { - // Use defaults if not specified - serverAddr := webhookOpts.ServerAddr - if serverAddr == "" { - serverAddr = defaultArgoCDServerAddr - } - - // Check for auth token from environment if not provided - authToken := webhookOpts.AuthToken - if authToken == "" { - if token := os.Getenv("ARGOCD_TOKEN"); token != "" { - authToken = token - } - } - - clientOpts := argocd.ClientOptions{ - ServerAddr: serverAddr, - Insecure: webhookOpts.Insecure, - Plaintext: webhookOpts.Plaintext, - GRPCWeb: webhookOpts.GRPCWeb, - AuthToken: authToken, - } - argoClient, err = argocd.NewAPIClient(&clientOpts) + cfg.ArgoClient, err = argocd.NewAPIClient(&cfg.ClientOpts) } if err != nil { @@ -116,14 +228,36 @@ func runWebhook() { handler := webhook.NewWebhookHandler() // Register supported webhook handlers - dockerHandler := webhook.NewDockerHubWebhook(webhookOpts.DockerSecret) + dockerHandler := webhook.NewDockerHubWebhook(webhookCfg.DockerSecret) handler.RegisterHandler(dockerHandler) - ghcrHandler := webhook.NewGHCRWebhook(webhookOpts.GHCRSecret) + ghcrHandler := webhook.NewGHCRWebhook(webhookCfg.GHCRSecret) handler.RegisterHandler(ghcrHandler) + quayHandler := webhook.NewQuayWebhook(webhookCfg.QuaySecret) + handler.RegisterHandler(quayHandler) + + harborHandler := webhook.NewHarborWebhook(webhookCfg.HarborSecret) + handler.RegisterHandler(harborHandler) + // Create webhook server - server := webhook.NewWebhookServer(webhookOpts.Port, handler, kubeClient, argoClient) + server := webhook.NewWebhookServer(webhookCfg.Port, handler, cfg.KubeClient, cfg.ArgoClient) + + // Set updater config + server.UpdaterConfig = &argocd.UpdateConfiguration{ + NewRegFN: registry.NewClient, + ArgoClient: cfg.ArgoClient, + KubeClient: cfg.KubeClient, + DryRun: cfg.DryRun, + GitCommitUser: cfg.GitCommitUser, + GitCommitEmail: cfg.GitCommitMail, + GitCommitMessage: cfg.GitCommitMessage, + GitCommitSigningKey: cfg.GitCommitSigningKey, + GitCommitSigningMethod: cfg.GitCommitSigningMethod, + GitCommitSignOff: cfg.GitCommitSignOff, + DisableKubeEvents: cfg.DisableKubeEvents, + GitCreds: cfg.GitCreds, + } // Set up graceful shutdown stop := make(chan os.Signal, 1) @@ -144,4 +278,5 @@ func runWebhook() { if err := server.Stop(); err != nil { log.Errorf("Error stopping webhook server: %v", err) } + return nil } diff --git a/pkg/webhook/docker.go b/pkg/webhook/docker.go index abdd8df3..ea84bea5 100644 --- a/pkg/webhook/docker.go +++ b/pkg/webhook/docker.go @@ -1,14 +1,10 @@ package webhook import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" "net/http" - "strings" ) // DockerHubWebhook handles Docker Hub webhook events @@ -34,23 +30,16 @@ func (d *DockerHubWebhook) Validate(r *http.Request) error { return fmt.Errorf("invalid HTTP method: %s", r.Method) } - // If secret is configured, validate the signature + // If secret is configured, validate it + // !! this is not that secure, docker does not have native secrets! if d.secret != "" { - signature := r.Header.Get("X-Hub-Signature-256") - if signature == "" { - return fmt.Errorf("missing webhook signature") + secret := r.URL.Query().Get("secret") + if secret == "" { + return fmt.Errorf("missing webhook secret") } - body, err := io.ReadAll(r.Body) - if err != nil { - return fmt.Errorf("failed to read request body: %w", err) - } - - // Reset body for later reading - r.Body = io.NopCloser(strings.NewReader(string(body))) - - if !d.validateSignature(body, signature) { - return fmt.Errorf("invalid webhook signature") + if secret != d.secret { + return fmt.Errorf("invalid webhook secret") } } @@ -103,18 +92,3 @@ func (d *DockerHubWebhook) Parse(r *http.Request) (*WebhookEvent, error) { Tag: payload.PushData.Tag, }, nil } - -// validateSignature validates the webhook signature using HMAC-SHA256 -func (d *DockerHubWebhook) validateSignature(body []byte, signature string) bool { - // Docker Hub signature format: sha256= - if !strings.HasPrefix(signature, "sha256=") { - return false - } - - expectedSig := signature[7:] // Remove "sha256=" prefix - mac := hmac.New(sha256.New, []byte(d.secret)) - mac.Write(body) - calculatedSig := hex.EncodeToString(mac.Sum(nil)) - - return hmac.Equal([]byte(expectedSig), []byte(calculatedSig)) -} diff --git a/pkg/webhook/docker_test.go b/pkg/webhook/docker_test.go index 00af7b74..3e81bcf4 100644 --- a/pkg/webhook/docker_test.go +++ b/pkg/webhook/docker_test.go @@ -2,9 +2,6 @@ package webhook import ( "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "io" "net/http/httptest" "strings" @@ -42,19 +39,19 @@ func TestDockerHubWebhook_Validate(t *testing.T) { name string method string body string - signature string + secret string noSecret bool expectError bool }{ { - name: "valid POST request with correct signature", + name: "valid POST request with correct secret", method: "POST", body: `{"test": "data"}`, - signature: generateSignature(secret, `{"test": "data"}`), + secret: "test-secret", expectError: false, }, { - name: "valid POST request without secret validation", + name: "valid POST request without secret", method: "POST", body: `{"test": "data"}`, noSecret: true, @@ -64,28 +61,21 @@ func TestDockerHubWebhook_Validate(t *testing.T) { name: "invalid HTTP method", method: "GET", body: `{"test": "data"}`, - signature: generateSignature(secret, `{"test": "data"}`), + secret: "test-secret", expectError: true, }, { - name: "missing signature when secret is configured", + name: "missing secret when secret is configured", method: "POST", body: `{"test": "data"}`, - signature: "", + secret: "", expectError: true, }, { - name: "invalid signature", + name: "invalid secret", method: "POST", body: `{"test": "data"}`, - signature: "sha256=invalid", - expectError: true, - }, - { - name: "signature for different body", - method: "POST", - body: `{"test": "data"}`, - signature: generateSignature(secret, `{"different": "data"}`), + secret: "not-the-secret", expectError: true, }, } @@ -98,8 +88,10 @@ func TestDockerHubWebhook_Validate(t *testing.T) { } req := httptest.NewRequest(tt.method, "/webhook", strings.NewReader(tt.body)) - if tt.signature != "" { - req.Header.Set("X-Hub-Signature-256", tt.signature) + if tt.secret != "" { + query := req.URL.Query() + query.Set("secret", tt.secret) + req.URL.RawQuery = query.Encode() } err := testWebhook.Validate(req) @@ -238,58 +230,6 @@ func TestDockerHubWebhook_Parse(t *testing.T) { } } -func TestDockerHubWebhook_validateSignature(t *testing.T) { - secret := "test-secret" - webhook := NewDockerHubWebhook(secret) - - tests := []struct { - name string - body string - signature string - expected bool - }{ - { - name: "valid signature", - body: `{"test": "data"}`, - signature: generateSignature(secret, `{"test": "data"}`), - expected: true, - }, - { - name: "invalid signature", - body: `{"test": "data"}`, - signature: "sha256=invalid", - expected: false, - }, - { - name: "signature without prefix", - body: `{"test": "data"}`, - signature: "invalid", - expected: false, - }, - { - name: "empty signature", - body: `{"test": "data"}`, - signature: "", - expected: false, - }, - { - name: "signature for different body", - body: `{"test": "data"}`, - signature: generateSignature(secret, `{"different": "data"}`), - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := webhook.validateSignature([]byte(tt.body), tt.signature) - if result != tt.expected { - t.Errorf("expected %v, got %v", tt.expected, result) - } - }) - } -} - func TestDockerHubWebhook_ParseWithBodyReuse(t *testing.T) { // Test that body can be read multiple times (e.g., after validation) secret := "test-secret" @@ -305,7 +245,9 @@ func TestDockerHubWebhook_ParseWithBodyReuse(t *testing.T) { }` req := httptest.NewRequest("POST", "/webhook", strings.NewReader(payload)) - req.Header.Set("X-Hub-Signature-256", generateSignature(secret, payload)) + query := req.URL.Query() + query.Set("secret", "test-secret") + req.URL.RawQuery = query.Encode() // First, validate the request err := webhook.Validate(req) @@ -328,13 +270,6 @@ func TestDockerHubWebhook_ParseWithBodyReuse(t *testing.T) { } } -// Helper function to generate HMAC-SHA256 signature for testing -func generateSignature(secret, body string) string { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(body)) - return "sha256=" + hex.EncodeToString(mac.Sum(nil)) -} - // Test helper to simulate reading request body multiple times func TestBodyReusability(t *testing.T) { originalBody := `{"test": "data"}` diff --git a/pkg/webhook/quay.go b/pkg/webhook/quay.go new file mode 100644 index 00000000..1f6f5136 --- /dev/null +++ b/pkg/webhook/quay.go @@ -0,0 +1,81 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +type QuayWebhook struct { + secret string +} + +func NewQuayWebhook(secret string) *QuayWebhook { + return &QuayWebhook{ + secret: secret, + } +} + +// GetRegistryType returns the type this handler supports +func (q *QuayWebhook) GetRegistryType() string { + return "quay.io" +} + +// Validates checks the Quay webhook payload to see if its valid +func (q *QuayWebhook) Validate(r *http.Request) error { + if r.Method != http.MethodPost { + return fmt.Errorf("invalid HTTP method: %s", r.Method) + } + + // Quay at the moment does not support secrets + // !! This query param method is NOT secure use at own risk + if q.secret != "" { + secret := r.URL.Query().Get("secret") + if secret == "" { + return fmt.Errorf("Missing webhook secret") + } + + if secret != q.secret { + return fmt.Errorf("Incorrect webhook secret") + } + } + + return nil +} + +// Parse process the Quay webhook and returns a Webhook event from the event +func (q *QuayWebhook) Parse(r *http.Request) (*WebhookEvent, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("failed to read request body: %w", err) + } + + // Quay Repository Push Event + // https://docs.quay.io/guides/notifications.html + var payload struct { + Name string `json:"name"` + Repository string `json:"repository"` + Namespace string `json:"namespace"` + DockerUrl string `json:"docker_url"` + Homepage string `json:"homepage"` + UpdatedTags []string `json:"updated_tags"` + } + + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("failed to parse webhook payload: %w", err) + } + + // Check updated tags for now just take first one + var tag string + if len(payload.UpdatedTags) == 0 { + return nil, fmt.Errorf("no tags in the payload") + } + tag = payload.UpdatedTags[0] + + return &WebhookEvent{ + RegistryURL: "quay.io", + Repository: payload.Repository, + Tag: tag, + }, nil +} diff --git a/pkg/webhook/quay_test.go b/pkg/webhook/quay_test.go new file mode 100644 index 00000000..b2a866fe --- /dev/null +++ b/pkg/webhook/quay_test.go @@ -0,0 +1,194 @@ +package webhook + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewQuayWebhook(t *testing.T) { + secret := "test" + webhook := NewDockerHubWebhook(secret) + + assert.NotNil(t, webhook, "webhook was nil") + assert.Equal(t, webhook.secret, secret, "Secret is not the same expected %s but got %s", secret, webhook.secret) +} + +func TestQuayWebhook_GetRegistryType(t *testing.T) { + webhook := NewQuayWebhook("") + registryType := webhook.GetRegistryType() + + assert.NotNil(t, webhook, "Webhook was nil") + assert.Equal(t, registryType, "quay.io", "Registry type is not quay.io got: %s", registryType) +} + +func TestQuayWebhook_Validate(t *testing.T) { + secret := "test-secret" + webhook := NewQuayWebhook(secret) + + tests := []struct { + name string + method string + body string + secret string + expectError bool + }{ + { + name: "valid POST request with correct secret", + method: "POST", + body: `{"test": "data"}`, + secret: "test-secret", + expectError: false, + }, + { + name: "valid POST request with incorrect secret", + method: "POST", + body: `{"test": "data"}`, + secret: "this-is-not-the-secret", + expectError: true, + }, + { + name: "incorrect method", + method: "GET", + body: `{"test": "data"}`, + secret: "test-secret", + expectError: true, + }, + { + name: "empty secret when secret is set", + method: "POST", + body: `{"test": "data"}`, + secret: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testWebhook := webhook + + req := httptest.NewRequest(tt.method, "/webhook", strings.NewReader(tt.body)) + if tt.secret != "" { + query := req.URL.Query() + query.Set("secret", tt.secret) + req.URL.RawQuery = query.Encode() + } + + err := testWebhook.Validate(req) + + if tt.expectError { + assert.Error(t, err) + } + if !tt.expectError { + assert.NoError(t, err) + } + }) + } +} + +func TestQuayWebhook_Parse(t *testing.T) { + webhook := NewQuayWebhook("") + + tests := []struct { + name string + payload string + expectedRepo string + expectedTag string + expectError bool + }{ + { + name: "valid payload", + payload: `{ + "name": "repository", + "repository": "mynamespace/repository", + "namespace": "mynamespace", + "docker_url": "quay.io/mynamespace/repository", + "homepage": "https://quay.io/repository/mynamespace/repository", + "updated_tags": [ + "latest" + ] + }`, + expectedRepo: "mynamespace/repository", + expectedTag: "latest", + expectError: false, + }, + { + name: "valid payload with multiple tags", + payload: `{ + "name": "repository", + "repository": "mynamespace/repository", + "namespace": "mynamespace", + "docker_url": "quay.io/mynamespace/repository", + "homepage": "https://quay.io/repository/mynamespace/repository", + "updated_tags": [ + "latest", + "v1.0" + ] + }`, + expectedRepo: "mynamespace/repository", + expectedTag: "latest", + expectError: false, + }, + { + name: "valid payload with no tags", + payload: `{ + "name": "repository", + "repository": "mynamespace/repository", + "namespace": "mynamespace", + "docker_url": "quay.io/mynamespace/repository", + "homepage": "https://quay.io/repository/mynamespace/repository", + "updated_tags": [ + ] + }`, + expectedRepo: "mynamespace/repository", + expectedTag: "", + expectError: true, + }, + { + name: "invalid payload", + payload: `{"invalid": "data"}`, + expectedRepo: "mynamespace/repository", + expectedTag: "latest", + expectError: true, + }, + { + name: "empty JSON payload", + payload: `{}`, + expectedRepo: "mynamespace/repository", + expectedTag: "latest", + expectError: true, + }, + { + name: "empty payload", + payload: ``, + expectedRepo: "mynamespace/repository", + expectedTag: "latest", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/webhook", strings.NewReader(tt.payload)) + + event, err := webhook.Parse(req) + + if tt.expectError { + assert.Error(t, err) + return + } + + if err != nil { + assert.NoError(t, err) + return + } + + assert.NotNil(t, event, "Event was nil") + assert.Equal(t, event.RegistryURL, "quay.io", "Expected repository url to be %s, but got %s", "quay.io", event.RegistryURL) + assert.Equal(t, event.Repository, tt.expectedRepo, "Expect repository to be %s, but got %s", tt.expectedRepo, event.Repository) + assert.Equal(t, event.Tag, tt.expectedTag, "Expected tag to be %s, but got %s", tt.expectedTag, event.Tag) + }) + } +} diff --git a/pkg/webhook/server.go b/pkg/webhook/server.go index 02ccb752..c53aab59 100644 --- a/pkg/webhook/server.go +++ b/pkg/webhook/server.go @@ -6,14 +6,13 @@ import ( "strings" "sync" - "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" - "github.com/argoproj-labs/argocd-image-updater/pkg/argocd" "github.com/argoproj-labs/argocd-image-updater/pkg/common" "github.com/argoproj-labs/argocd-image-updater/pkg/kube" "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/image" "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log" - "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/registry" + + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" ) // WebhookServer manages webhook endpoints and triggers update checks @@ -23,7 +22,7 @@ type WebhookServer struct { // Handler is the webhook handler Handler *WebhookHandler // UpdaterConfig holds configuration for image updating - UpdaterConfig *argocd.UpdaterConfig + UpdaterConfig *argocd.UpdateConfiguration // KubeClient is the Kubernetes client KubeClient *kube.ImageUpdaterKubernetesClient // ArgoClient is the ArgoCD client @@ -32,6 +31,8 @@ type WebhookServer struct { Server *http.Server // mutex for concurrent update operations mutex sync.Mutex + // mutex for concurrent repo access + syncState *argocd.SyncIterationState } // NewWebhookServer creates a new webhook server @@ -41,6 +42,7 @@ func NewWebhookServer(port int, handler *WebhookHandler, kubeClient *kube.ImageU Handler: handler, KubeClient: kubeClient, ArgoClient: argoClient, + syncState: argocd.NewSyncIterationState(), } } @@ -143,19 +145,10 @@ func (s *WebhookServer) processWebhookEvent(event *WebhookEvent) error { appLogCtx.Infof("Triggering image update check for application") // Create update configuration for this application - updateConf := &argocd.UpdateConfiguration{ - NewRegFN: registry.NewClient, - ArgoClient: s.ArgoClient, - KubeClient: s.KubeClient, - UpdateApp: &appImages, - DryRun: false, - GitCommitUser: s.UpdaterConfig.GitCommitUser, - GitCommitEmail: s.UpdaterConfig.GitCommitEmail, - DisableKubeEvents: false, - } + s.UpdaterConfig.UpdateApp = &appImages // Run the update process - result := argocd.UpdateApplication(updateConf, argocd.NewSyncIterationState()) + result := argocd.UpdateApplication(s.UpdaterConfig, s.syncState) appLogCtx.Infof("Update result: processed=%d, updated=%d, errors=%d, skipped=%d", result.NumApplicationsProcessed, result.NumImagesUpdated, result.NumErrors, result.NumSkipped)