github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/hmac/main.go (about) 1 /* 2 Copyright 2020 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package main 18 19 import ( 20 "context" 21 "crypto/rand" 22 "encoding/hex" 23 "errors" 24 "flag" 25 "fmt" 26 "os" 27 "sort" 28 "strings" 29 "time" 30 31 "github.com/sirupsen/logrus" 32 corev1 "k8s.io/api/core/v1" 33 apierrors "k8s.io/apimachinery/pkg/api/errors" 34 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 35 utilerrors "k8s.io/apimachinery/pkg/util/errors" 36 "k8s.io/client-go/kubernetes" 37 "sigs.k8s.io/yaml" 38 39 "sigs.k8s.io/prow/pkg/config" 40 "sigs.k8s.io/prow/pkg/flagutil" 41 prowflagutil "sigs.k8s.io/prow/pkg/flagutil" 42 configflagutil "sigs.k8s.io/prow/pkg/flagutil/config" 43 "sigs.k8s.io/prow/pkg/ghhook" 44 "sigs.k8s.io/prow/pkg/github" 45 "sigs.k8s.io/prow/pkg/logrusutil" 46 ) 47 48 type options struct { 49 config configflagutil.ConfigOptions 50 51 dryRun bool 52 github prowflagutil.GitHubOptions 53 kubernetes prowflagutil.KubernetesOptions 54 kubeconfigCtx string 55 56 hookUrl string 57 hmacTokenSecretNamespace string 58 hmacTokenSecretName string 59 hmacTokenKey string 60 } 61 62 func (o *options) validate() error { 63 for _, group := range []flagutil.OptionGroup{&o.kubernetes, &o.github, &o.config} { 64 if err := group.Validate(o.dryRun); err != nil { 65 return err 66 } 67 } 68 69 if o.kubeconfigCtx == "" { 70 return errors.New("required flag --kubeconfig-context was unset") 71 } 72 if o.hookUrl == "" { 73 return errors.New("required flag --hook-url was unset") 74 } 75 if o.hmacTokenSecretName == "" { 76 return errors.New("required flag --hmac-token-secret-name was unset") 77 } 78 if o.hmacTokenKey == "" { 79 return errors.New("required flag --hmac-token-key was unset") 80 } 81 82 return nil 83 } 84 85 func gatherOptions(fs *flag.FlagSet, args ...string) options { 86 var o options 87 88 o.config.AddFlags(fs) 89 o.github.AddFlags(fs) 90 o.kubernetes.AddFlags(fs) 91 92 fs.StringVar(&o.kubeconfigCtx, "kubeconfig-context", "", "Context of the Prow component cluster and namespace in the kubeconfig.") 93 fs.BoolVar(&o.dryRun, "dry-run", true, "Dry run for testing. Uses API tokens but does not mutate.") 94 95 fs.StringVar(&o.hookUrl, "hook-url", "", "Prow hook external webhook URL (e.g. https://prow.k8s.io/hook).") 96 fs.StringVar(&o.hmacTokenSecretNamespace, "hmac-token-secret-namespace", "default", "Name of the namespace on the cluster where the hmac-token secret is in.") 97 fs.StringVar(&o.hmacTokenSecretName, "hmac-token-secret-name", "", "Name of the secret on the cluster containing the GitHub HMAC secret.") 98 fs.StringVar(&o.hmacTokenKey, "hmac-token-key", "", "Key of the hmac token in the secret.") 99 fs.Parse(args) 100 return o 101 } 102 103 type client struct { 104 options options 105 106 kubernetesClient kubernetes.Interface 107 githubHookClient github.HookClient 108 109 currentHMACMap map[string]github.HMACsForRepo 110 newHMACConfig config.ManagedWebhooks 111 112 hmacMapForBatchUpdate map[string]string 113 hmacMapForRecovery map[string]github.HMACsForRepo 114 } 115 116 func main() { 117 logrusutil.ComponentInit() 118 119 o := gatherOptions(flag.NewFlagSet(os.Args[0], flag.ExitOnError), os.Args[1:]...) 120 if err := o.validate(); err != nil { 121 logrus.WithError(err).Fatal("Invalid options") 122 } 123 124 kc, err := o.kubernetes.ClusterClientForContext(o.kubeconfigCtx, o.dryRun) 125 if err != nil { 126 logrus.WithError(err).Fatalf("Error creating Kubernetes client for cluster %q.", o.kubeconfigCtx) 127 } 128 129 configAgent, err := o.config.ConfigAgent() 130 if err != nil { 131 logrus.WithError(err).Fatal("Error starting config agent.") 132 } 133 newHMACConfig := configAgent.Config().ManagedWebhooks 134 135 gc, err := o.github.GitHubClient(o.dryRun) 136 if err != nil { 137 logrus.WithError(err).Fatal("Error creating github client") 138 } 139 140 currentHMACYaml, err := getCurrentHMACTokens(kc, o.hmacTokenSecretNamespace, o.hmacTokenSecretName, o.hmacTokenKey) 141 if err != nil { 142 logrus.WithError(err).Fatal("Error getting the current hmac yaml.") 143 } 144 145 currentHMACMap := map[string]github.HMACsForRepo{} 146 if err := yaml.Unmarshal(currentHMACYaml, ¤tHMACMap); err != nil { 147 // When the token is still a single global token, respect_legacy_global_token must be set to true before running this tool. 148 // This can prevent the global token from being deleted by mistake before users migrate all repos/orgs to use auto-generated private tokens. 149 if !newHMACConfig.RespectLegacyGlobalToken { 150 logrus.Fatal("respect_legacy_global_token must be set to true before the hmac tool is run for the first time.") 151 } 152 153 logrus.WithError(err).Error("Couldn't unmarshal the hmac secret as hierarchical file. Parsing as a single global token and writing it back to the secret.") 154 currentHMACMap["*"] = github.HMACsForRepo{ 155 github.HMACToken{ 156 Value: strings.TrimSpace(string(currentHMACYaml)), 157 }, 158 } 159 } 160 161 c := client{ 162 kubernetesClient: kc, 163 githubHookClient: gc, 164 options: o, 165 166 currentHMACMap: currentHMACMap, 167 newHMACConfig: newHMACConfig, 168 hmacMapForBatchUpdate: map[string]string{}, 169 hmacMapForRecovery: map[string]github.HMACsForRepo{}, 170 } 171 172 if err := c.handleInvitation(); err != nil { 173 logrus.WithError(err).Fatal("Error accepting invitations.") 174 } 175 176 if err := c.handleConfigUpdate(); err != nil { 177 logrus.WithError(err).Fatal("Error handling hmac config update.") 178 } 179 } 180 181 func (c *client) handleInvitation() error { 182 if !c.newHMACConfig.AutoAcceptInvitation { 183 logrus.Debug("Skip accepting github invitations as not configured.") 184 return nil 185 } 186 // Accept repos invitations first 187 repoIvs, err := c.githubHookClient.ListCurrentUserRepoInvitations() 188 if err != nil { 189 return err 190 } 191 for _, iv := range repoIvs { 192 if iv.Permission != github.Admin { 193 logrus.Errorf("invalid invitation from %s is not accepted. Permission want: %v, got: %s", 194 iv.Repository.FullName, github.Admin, iv.Permission) 195 continue 196 } 197 for repoName := range c.newHMACConfig.OrgRepoConfig { 198 // Only consider strict matching for repo level invitation, 199 // reasons for not considering org matching: 200 // 1. The FullName is org/repo 201 // 2. If an org is defined as managed webhook but only invite 202 // bot as admin on repo level, the webhook setup will fail 203 // 3. Also we are not ready to receive spamming webhook from the 204 // org if it only configured a repo in hmac 205 if iv.Repository.FullName == repoName { 206 if err := c.githubHookClient.AcceptUserRepoInvitation(iv.InvitationID); err != nil { 207 return fmt.Errorf("failed accepting repo invitation from %s: %w", iv.Repository.FullName, err) 208 } 209 logrus.Infof("Successfully accepted invitation from %s", iv.Repository.FullName) 210 } 211 } 212 } 213 // Accept org invitation 214 orgIvs, err := c.githubHookClient.ListCurrentUserOrgInvitations() 215 if err != nil { 216 return err 217 } 218 for _, iv := range orgIvs { 219 if iv.Role != github.OrgAdmin { 220 logrus.Errorf("Invalid invitation from %s not accepted. Want: %v, got: %s", 221 iv.Org.Login, github.Admin, iv.Role) 222 continue 223 } 224 for repoName := range c.newHMACConfig.OrgRepoConfig { 225 // Accept org invitation even if only single repo want hmac 226 if repoName == iv.Org.Login || strings.HasPrefix(repoName, iv.Org.Login+"/") { 227 if err := c.githubHookClient.AcceptUserOrgInvitation(iv.Org.Login); err != nil { 228 return fmt.Errorf("failed accepting org invitation from %s: %w", iv.Org.Login, err) 229 } 230 logrus.Infof("Successfully accepted invitation from %s", iv.Org.Login) 231 } 232 } 233 } 234 235 return nil 236 } 237 238 func (c *client) handleConfigUpdate() error { 239 repoAdded := map[string]config.ManagedWebhookInfo{} 240 repoRemoved := map[string]bool{} 241 repoRotated := map[string]config.ManagedWebhookInfo{} 242 243 for repoName, hmacConfig := range c.newHMACConfig.OrgRepoConfig { 244 if _, ok := c.currentHMACMap[repoName]; ok { 245 repoRotated[repoName] = hmacConfig 246 } else { 247 repoAdded[repoName] = hmacConfig 248 } 249 } 250 251 for repoName := range c.currentHMACMap { 252 // Skip the global hmac token if it still needs to be respected. 253 if repoName == "*" && c.newHMACConfig.RespectLegacyGlobalToken { 254 continue 255 } 256 if _, ok := c.newHMACConfig.OrgRepoConfig[repoName]; !ok { 257 repoRemoved[repoName] = true 258 } 259 } 260 261 // Remove the webhooks for the given repos, as well as removing the tokens from the current hmac map. 262 if err := c.handleRemovedRepo(repoRemoved); err != nil { 263 return fmt.Errorf("error handling hmac update for removed repos: %w", err) 264 } 265 266 // Generate new hmac token for required repos, do batch update for the hmac token secret, 267 // and then iteratively update the webhook for each repo. 268 if err := c.handleAddedRepo(repoAdded); err != nil { 269 return fmt.Errorf("error handling hmac update for new repos: %w", err) 270 } 271 if err := c.handledRotatedRepo(repoRotated); err != nil { 272 return fmt.Errorf("error handling hmac rotations for the repos: %w", err) 273 } 274 // Update the hmac token secret first, to guarantee the new tokens are available to hook. 275 if err := c.updateHMACTokenSecret(); err != nil { 276 return fmt.Errorf("error updating hmac tokens: %w", err) 277 } 278 // HACK: waiting for the hmac k8s secret update to propagate to the pods that are using the secret, 279 // so that components like hook can start respecting the new hmac values. 280 time.Sleep(20 * time.Second) 281 errs := c.batchOnboardNewTokenForRepos() 282 283 // Do necessary cleanups after the token and webhook updates are done. 284 if err := c.cleanup(); err != nil { 285 errs = append(errs, fmt.Errorf("error cleaning up %w", err)) 286 } 287 288 return utilerrors.NewAggregate(errs) 289 } 290 291 // handleRemoveRepo handles webhook removal and hmac token removal from the current hmac map for all repos removed from the declarative config. 292 func (c *client) handleRemovedRepo(removed map[string]bool) error { 293 removeGlobalToken := false 294 repos := make([]string, 0) 295 for r := range removed { 296 if r == "*" { 297 removeGlobalToken = true 298 } else { 299 repos = append(repos, r) 300 } 301 } 302 303 if len(repos) != 0 { 304 o := ghhook.Options{ 305 GitHubOptions: c.options.github, 306 GitHubHookClient: c.githubHookClient, 307 Repos: prowflagutil.NewStrings(repos...), 308 HookURL: c.options.hookUrl, 309 ShouldDelete: true, 310 Confirm: true, 311 } 312 if err := o.Validate(); err != nil { 313 return fmt.Errorf("error validating the options: %w", err) 314 } 315 316 logrus.WithField("repos", repos).Debugf("Deleting webhooks for %q", c.options.hookUrl) 317 if err := o.HandleWebhookConfigChange(); err != nil { 318 return fmt.Errorf("error deleting webhook for repos %q: %w", repos, err) 319 } 320 321 for _, repo := range repos { 322 delete(c.currentHMACMap, repo) 323 } 324 } 325 326 if removeGlobalToken { 327 delete(c.currentHMACMap, "*") 328 } 329 // No need to update the secret here, the following update will commit the changes together. 330 331 return nil 332 } 333 334 func (c *client) handleAddedRepo(added map[string]config.ManagedWebhookInfo) error { 335 for repo := range added { 336 if err := c.addRepoToBatchUpdate(repo); err != nil { 337 return err 338 } 339 } 340 return nil 341 } 342 343 func (c *client) handledRotatedRepo(rotated map[string]config.ManagedWebhookInfo) error { 344 // For each rotated repo, we only onboard a new token when none of the existing tokens is created after user specified time. 345 for repo, hmacConfig := range rotated { 346 needsRotation := true 347 for _, token := range c.currentHMACMap[repo] { 348 // If the existing token is created after the user specified time, we do not need to rotate it. 349 if token.CreatedAt.After(hmacConfig.TokenCreatedAfter) { 350 needsRotation = false 351 break 352 } 353 } 354 if needsRotation { 355 if err := c.addRepoToBatchUpdate(repo); err != nil { 356 return err 357 } 358 } 359 } 360 return nil 361 } 362 363 func (c *client) addRepoToBatchUpdate(repo string) error { 364 generatedToken, err := generateNewHMACToken() 365 if err != nil { 366 return fmt.Errorf("error generating a new hmac token for %q: %w", repo, err) 367 } 368 369 updatedTokenList := github.HMACsForRepo{} 370 // Copy over all existing tokens for that repo, if it's already been configured. 371 if val, ok := c.currentHMACMap[repo]; ok { 372 updatedTokenList = append(updatedTokenList, val...) 373 // Back up the hmacs for the current repo, which we can use for recovery in case an error happens in updating the webhook. 374 c.hmacMapForRecovery[repo] = c.currentHMACMap[repo] 375 // Current webhook is possibly using global token so we need to promote that token to repo level, if it exists. 376 } else if globalTokens, ok := c.currentHMACMap["*"]; ok { 377 updatedTokenList = append(updatedTokenList, github.HMACToken{ 378 Value: globalTokens[0].Value, 379 // Set CreatedAt as a time slightly before the TokenCreatedAfter time, so that the token can be properly pruned in the end. 380 CreatedAt: c.newHMACConfig.OrgRepoConfig[repo].TokenCreatedAfter.Add(-time.Second), 381 }) 382 } 383 384 updatedTokenList = append(updatedTokenList, github.HMACToken{ 385 Value: generatedToken, CreatedAt: time.Now()}) 386 c.currentHMACMap[repo] = updatedTokenList 387 c.hmacMapForBatchUpdate[repo] = generatedToken 388 389 return nil 390 } 391 392 func (c *client) onboardNewTokenForRepo(repo, generatedToken string) error { 393 // Update the github webhook to use new token. 394 o := ghhook.Options{ 395 GitHubOptions: c.options.github, 396 GitHubHookClient: c.githubHookClient, 397 Repos: prowflagutil.NewStrings(repo), 398 HookURL: c.options.hookUrl, 399 HMACValue: generatedToken, 400 // Receive hooks for all the events. 401 Events: prowflagutil.NewStrings(github.AllHookEvents...), 402 Confirm: true, 403 } 404 if err := o.Validate(); err != nil { 405 return fmt.Errorf("error validating the options: %w", err) 406 } 407 408 logrus.WithField("repo", repo).Debugf("Updating the webhook for %q", c.options.hookUrl) 409 return o.HandleWebhookConfigChange() 410 } 411 412 func (c *client) batchOnboardNewTokenForRepos() []error { 413 var errs []error 414 for repo, generatedToken := range c.hmacMapForBatchUpdate { 415 if err := c.onboardNewTokenForRepo(repo, generatedToken); err != nil { 416 errs = append(errs, err) 417 logrus.WithError(err).Errorf("Error updating the webhook, will revert the hmacs for %q", repo) 418 if hmacs, exist := c.hmacMapForRecovery[repo]; exist { 419 c.currentHMACMap[repo] = hmacs 420 } else { 421 delete(c.currentHMACMap, repo) 422 } 423 } 424 } 425 return errs 426 } 427 428 // cleanup will do necessary cleanups after the token and webhook updates are done. 429 func (c *client) cleanup() error { 430 // Prune old tokens from current config. 431 for repoName := range c.currentHMACMap { 432 c.pruneOldTokens(repoName) 433 } 434 // Update the secret. 435 if err := c.updateHMACTokenSecret(); err != nil { 436 return fmt.Errorf("error updating hmac tokens: %w", err) 437 } 438 return nil 439 } 440 441 // updateHMACTokenSecret saves given in-memory config to secret file used by prow cluster. 442 func (c *client) updateHMACTokenSecret() error { 443 if c.options.dryRun { 444 logrus.Debug("dryrun option is enabled, updateHMACTokenSecret won't actually update the secret.") 445 return nil 446 } 447 448 secretContent, err := yaml.Marshal(&c.currentHMACMap) 449 if err != nil { 450 return fmt.Errorf("error converting hmac map to yaml: %w", err) 451 } 452 sec := &corev1.Secret{} 453 sec.Name = c.options.hmacTokenSecretName 454 sec.Namespace = c.options.hmacTokenSecretNamespace 455 sec.StringData = map[string]string{c.options.hmacTokenKey: string(secretContent)} 456 if _, err = c.kubernetesClient.CoreV1().Secrets(c.options.hmacTokenSecretNamespace).Update(context.TODO(), sec, metav1.UpdateOptions{}); err != nil { 457 return fmt.Errorf("error updating the secret: %w", err) 458 } 459 return nil 460 } 461 462 // pruneOldTokens removes all but most recent token from token config. 463 func (c *client) pruneOldTokens(repo string) { 464 tokens := c.currentHMACMap[repo] 465 if len(tokens) <= 1 { 466 logrus.WithField("repo", repo).Debugf("Token size is %d, no need to prune", len(tokens)) 467 return 468 } 469 470 logrus.WithField("repo", repo).Debugf("Token size is %d, prune to 1", len(tokens)) 471 sort.SliceStable(tokens, func(i, j int) bool { 472 return tokens[i].CreatedAt.After(tokens[j].CreatedAt) 473 }) 474 c.currentHMACMap[repo] = tokens[:1] 475 } 476 477 // generateNewHMACToken generates a hex encoded crypto random string of length 40. 478 func generateNewHMACToken() (string, error) { 479 bytes := make([]byte, 20) // 20 bytes of entropy will result in a string of length 40 after hex encoding 480 if _, err := rand.Read(bytes); err != nil { 481 return "", err 482 } 483 return hex.EncodeToString(bytes), nil 484 } 485 486 // getCurrentHMACTokens returns the hmac tokens currently configured in the cluster. 487 func getCurrentHMACTokens(kc kubernetes.Interface, ns, secName, key string) ([]byte, error) { 488 sec, err := kc.CoreV1().Secrets(ns).Get(context.TODO(), secName, metav1.GetOptions{}) 489 if err != nil && !apierrors.IsNotFound(err) { 490 return nil, fmt.Errorf("error getting hmac secret %q: %w", secName, err) 491 } 492 if err == nil { 493 buf, ok := sec.Data[key] 494 if ok { 495 return buf, nil 496 } 497 return nil, fmt.Errorf("error getting key %q from the hmac secret %q", key, secName) 498 } 499 return nil, fmt.Errorf("error getting hmac token values: %w", err) 500 }