github.com/letsencrypt/boulder@v0.20251208.0/sfe/overridesimporter.go (about)

     1  package sfe
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"net/netip"
     7  	"net/url"
     8  	"strconv"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/jmhodges/clock"
    13  	"google.golang.org/protobuf/types/known/durationpb"
    14  
    15  	"github.com/letsencrypt/boulder/identifier"
    16  	blog "github.com/letsencrypt/boulder/log"
    17  	rapb "github.com/letsencrypt/boulder/ra/proto"
    18  	rl "github.com/letsencrypt/boulder/ratelimits"
    19  	"github.com/letsencrypt/boulder/sfe/zendesk"
    20  )
    21  
    22  // ProcessMode determines which ticket IDs the importer will process.
    23  type ProcessMode string
    24  
    25  const (
    26  	// ProcessAll indicates that all tickets should be processed, as opposed to
    27  	// just even or odd numbered tickets.
    28  	ProcessAll  ProcessMode = "all"
    29  	processEven ProcessMode = "even"
    30  	processOdd  ProcessMode = "odd"
    31  )
    32  
    33  type OverridesImporter struct {
    34  	mode     ProcessMode
    35  	interval time.Duration
    36  
    37  	zendesk *zendesk.Client
    38  	ra      rapb.RegistrationAuthorityClient
    39  
    40  	clk clock.Clock
    41  	log blog.Logger
    42  }
    43  
    44  // NewOverridesImporter creates a new OverridesImporter that will process
    45  // tickets in the given mode at the given interval. An error is returned if the
    46  // interval is left unspecified.
    47  func NewOverridesImporter(mode ProcessMode, interval time.Duration, client *zendesk.Client, sa rapb.RegistrationAuthorityClient, clk clock.Clock, log blog.Logger) (*OverridesImporter, error) {
    48  	if interval <= 0 {
    49  		return nil, fmt.Errorf("interval cannot be 0")
    50  	}
    51  	return &OverridesImporter{
    52  		mode:     mode,
    53  		interval: interval,
    54  		zendesk:  client,
    55  		ra:       sa,
    56  		clk:      clk,
    57  		log:      log,
    58  	}, nil
    59  }
    60  
    61  // Start begins the periodic import of approved override requests from Zendesk.
    62  // This method blocks until the provided context is cancelled.
    63  func (im *OverridesImporter) Start(ctx context.Context) {
    64  	ticker := time.NewTicker(im.interval)
    65  	defer ticker.Stop()
    66  
    67  	for {
    68  		select {
    69  		case <-ctx.Done():
    70  			return
    71  		case <-ticker.C:
    72  			im.tick(ctx)
    73  		}
    74  	}
    75  }
    76  
    77  // tick performs a single import pass, serially processing all tickets in the
    78  // configured mode that have been marked "open" and "approved".
    79  func (im *OverridesImporter) tick(ctx context.Context) {
    80  	tickets, err := im.zendesk.FindTickets(map[string]string{ReviewStatusFieldName: reviewStatusApproved}, "open")
    81  	if err != nil {
    82  		im.log.Errf("while searching zendesk for solved and approved tickets: %s", err)
    83  		return
    84  	}
    85  
    86  	processed := 0
    87  	failures := 0
    88  	for id, fields := range tickets {
    89  		switch im.mode {
    90  		case processEven:
    91  			if id%2 != 0 {
    92  				continue
    93  			}
    94  		case processOdd:
    95  			if id%2 == 0 {
    96  				continue
    97  			}
    98  		}
    99  
   100  		err = im.processTicket(ctx, id, fields)
   101  		if err != nil {
   102  			im.log.Errf("while processing ticket %d: %s", id, err)
   103  			failures++
   104  			continue
   105  		}
   106  		processed++
   107  	}
   108  	im.log.Infof("overrides importer processed %d tickets with %d failures", processed, failures)
   109  }
   110  
   111  func accountURIToID(s string) (int64, error) {
   112  	u, err := url.Parse(s)
   113  	if err != nil {
   114  		return 0, err
   115  	}
   116  	rawID := strings.TrimPrefix(u.Path, "/acme/acct/")
   117  	return strconv.ParseInt(rawID, 10, 64)
   118  }
   119  
   120  // transitionToPendingWithComment sets the status of the given ticket to
   121  // "pending" and adds a private comment with the given cause. If updating the
   122  // ticket fails, the error is logged.
   123  func (im *OverridesImporter) transitionToPendingWithComment(ticketID int64, cause string) {
   124  	privateBody := fmt.Sprintf(
   125  		"A failure occurred while importing this override:\n\n%s\n\n"+
   126  			"This ticket's status has been set to pending.\n\n"+
   127  			"Once the error has been corrected, change the status back to \"open\" to retry.\n",
   128  		cause,
   129  	)
   130  	err := im.zendesk.UpdateTicketStatus(ticketID, "pending", privateBody, false)
   131  	if err != nil {
   132  		im.log.Errf("failed to update ticket %d: %s", ticketID, err)
   133  	}
   134  }
   135  
   136  func getValidatedFieldValue(fields map[string]string, fieldName, rateLimit string) (string, error) {
   137  	val := fields[fieldName]
   138  	err := validateOverrideRequestField(fieldName, val, rateLimit)
   139  	if err != nil {
   140  		return "", err
   141  	}
   142  	return val, nil
   143  }
   144  
   145  func makeAddOverrideRequest(rateLimitFieldValue string, fields map[string]string) (*rapb.AddRateLimitOverrideRequest, string, error) {
   146  	makeReq := func(limit rl.Name, bucket, organization string, tier int64) *rapb.AddRateLimitOverrideRequest {
   147  		return &rapb.AddRateLimitOverrideRequest{
   148  			LimitEnum: int64(limit),
   149  			BucketKey: bucket,
   150  			Count:     tier,
   151  			Burst:     tier,
   152  			Period:    durationpb.New(7 * 24 * time.Hour),
   153  			Comment:   organization,
   154  		}
   155  	}
   156  
   157  	if rateLimitFieldValue == "" {
   158  		return nil, "", fmt.Errorf("missing rate limit field")
   159  	}
   160  	tierStr, err := getValidatedFieldValue(fields, TierFieldName, rateLimitFieldValue)
   161  	if err != nil {
   162  		return nil, "", fmt.Errorf("getting/validating tier field: %w", err)
   163  	}
   164  	tier, err := strconv.ParseInt(tierStr, 10, 64)
   165  	if err != nil {
   166  		return nil, "", fmt.Errorf("parsing tier: %w", err)
   167  	}
   168  	organization, err := getValidatedFieldValue(fields, OrganizationFieldName, "")
   169  	if err != nil {
   170  		return nil, "", fmt.Errorf("getting/validating organization: %w", err)
   171  	}
   172  
   173  	var req *rapb.AddRateLimitOverrideRequest
   174  	var accountDomainOrIP string
   175  
   176  	switch rateLimitFieldValue {
   177  	case rl.NewOrdersPerAccount.String():
   178  		accountURI, err := getValidatedFieldValue(fields, AccountURIFieldName, "")
   179  		if err != nil {
   180  			return nil, "", fmt.Errorf("getting/validating accountURI: %w", err)
   181  		}
   182  		accountID, err := accountURIToID(accountURI)
   183  		if err != nil {
   184  			return nil, "", fmt.Errorf("parsing accountURI to accountID: %w", err)
   185  		}
   186  		bucketKey, err := rl.BuildBucketKey(rl.NewOrdersPerAccount, accountID, identifier.ACMEIdentifier{}, identifier.ACMEIdentifiers{}, netip.Addr{})
   187  		if err != nil {
   188  			return nil, "", fmt.Errorf("building bucket key: %w", err)
   189  		}
   190  		req = makeReq(rl.NewOrdersPerAccount, bucketKey, organization, tier)
   191  		accountDomainOrIP = accountURI
   192  
   193  	case rl.CertificatesPerDomainPerAccount.String():
   194  		accountURI, err := getValidatedFieldValue(fields, AccountURIFieldName, "")
   195  		if err != nil {
   196  			return nil, "", fmt.Errorf("getting/validating accountURI: %w", err)
   197  		}
   198  		accountID, err := accountURIToID(accountURI)
   199  		if err != nil {
   200  			return nil, "", fmt.Errorf("parsing accountURI to accountID: %w", err)
   201  		}
   202  		bucketKey, err := rl.BuildBucketKey(rl.CertificatesPerDomainPerAccount, accountID, identifier.ACMEIdentifier{}, identifier.ACMEIdentifiers{}, netip.Addr{})
   203  		if err != nil {
   204  			return nil, "", fmt.Errorf("building bucket key: %w", err)
   205  		}
   206  		req = makeReq(rl.CertificatesPerDomainPerAccount, bucketKey, organization, tier)
   207  		accountDomainOrIP = accountURI
   208  
   209  	case rl.CertificatesPerDomain.String() + perDNSNameSuffix:
   210  		dnsName, err := getValidatedFieldValue(fields, RegisteredDomainFieldName, rateLimitFieldValue)
   211  		if err != nil {
   212  			return nil, "", fmt.Errorf("getting/validating registeredDomain: %w", err)
   213  		}
   214  		bucketKey, err := rl.BuildBucketKey(rl.CertificatesPerDomain, 0, identifier.NewDNS(dnsName), identifier.ACMEIdentifiers{}, netip.Addr{})
   215  		if err != nil {
   216  			return nil, "", fmt.Errorf("building bucket key: %w", err)
   217  		}
   218  		accountDomainOrIP = dnsName
   219  		req = makeReq(rl.CertificatesPerDomain, bucketKey, organization, tier)
   220  
   221  	case rl.CertificatesPerDomain.String() + perIPSuffix:
   222  		ipAddrStr, err := getValidatedFieldValue(fields, IPAddressFieldName, rateLimitFieldValue)
   223  		if err != nil {
   224  			return nil, "", fmt.Errorf("getting/validating ipAddress: %w", err)
   225  		}
   226  		ipAddr, err := netip.ParseAddr(ipAddrStr)
   227  		if err != nil {
   228  			return nil, "", fmt.Errorf("parsing ipAddress: %w", err)
   229  		}
   230  		bucketKey, err := rl.BuildBucketKey(rl.CertificatesPerDomain, 0, identifier.NewIP(ipAddr), identifier.ACMEIdentifiers{}, netip.Addr{})
   231  		if err != nil {
   232  			return nil, "", fmt.Errorf("building bucket key: %w", err)
   233  		}
   234  		req = makeReq(rl.CertificatesPerDomain, bucketKey, organization, tier)
   235  		accountDomainOrIP = ipAddr.String()
   236  
   237  	default:
   238  		return nil, "", fmt.Errorf("unknown rate limit")
   239  	}
   240  	return req, accountDomainOrIP, nil
   241  }
   242  
   243  func (im *OverridesImporter) processTicket(ctx context.Context, ticketID int64, fields map[string]string) error {
   244  	req, accountDomainOrIP, err := makeAddOverrideRequest(fields[RateLimitFieldName], fields)
   245  	if err != nil {
   246  		// Move to "pending" so the next tick won't comment again.
   247  		im.transitionToPendingWithComment(ticketID, err.Error())
   248  		return fmt.Errorf("preparing override request: %w", err)
   249  	}
   250  
   251  	resp, err := im.ra.AddRateLimitOverride(ctx, req)
   252  	if err != nil {
   253  		// This is likely a transient error, we'll re-attempt on the next pass.
   254  		return fmt.Errorf("calling ra.AddRateLimitOverride: %w", err)
   255  	}
   256  
   257  	rateLimit := rl.Name(req.LimitEnum).String()
   258  	if !resp.Enabled {
   259  		// Move to "pending" so the next tick won't comment again.
   260  		im.transitionToPendingWithComment(ticketID, "An existing override for this limit and requester is currently administratively disabled.")
   261  		return fmt.Errorf("override for rate limit %s and account/domain/IP: %s is administratively disabled", rateLimit, accountDomainOrIP)
   262  	}
   263  
   264  	// The "30 minutes" promised in the comment below must be kept in sync with
   265  	// the override reloading interval values (overrideRefresherShutdown) in:
   266  	//   - cmd/boulder-ra/main.go and
   267  	//   - cmd/boulder-wfe/main.go
   268  	successCommentBody := fmt.Sprintf(
   269  		"Your override request for rate limit %s and account/domain/IP: %s "+
   270  			"has been approved. Your new limit is %d per period (see: https://letsencrypt.org/docs/rate-limits for the period). "+
   271  			"Please allow up to 30 minutes for this change to take effect.",
   272  		rateLimit, accountDomainOrIP, req.Count,
   273  	)
   274  
   275  	err = im.zendesk.UpdateTicketStatus(ticketID, "solved", successCommentBody, true)
   276  	if err != nil {
   277  		return fmt.Errorf("transitioning ticket %d to solved with a comment: %w", ticketID, err)
   278  	}
   279  	return nil
   280  }