github.com/letsencrypt/boulder@v0.20251208.0/cmd/admin/pause_identifier.go (about)

     1  package main
     2  
     3  import (
     4  	"context"
     5  	"encoding/csv"
     6  	"errors"
     7  	"flag"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"strconv"
    12  	"sync"
    13  	"sync/atomic"
    14  
    15  	corepb "github.com/letsencrypt/boulder/core/proto"
    16  	"github.com/letsencrypt/boulder/identifier"
    17  	sapb "github.com/letsencrypt/boulder/sa/proto"
    18  )
    19  
    20  // subcommandPauseIdentifier encapsulates the "admin pause-identifiers" command.
    21  type subcommandPauseIdentifier struct {
    22  	batchFile   string
    23  	parallelism uint
    24  }
    25  
    26  var _ subcommand = (*subcommandPauseIdentifier)(nil)
    27  
    28  func (p *subcommandPauseIdentifier) Desc() string {
    29  	return "Administratively pause an account preventing it from attempting certificate issuance"
    30  }
    31  
    32  func (p *subcommandPauseIdentifier) Flags(flag *flag.FlagSet) {
    33  	flag.StringVar(&p.batchFile, "batch-file", "", "Path to a CSV file containing (account ID, identifier type, identifier value)")
    34  	flag.UintVar(&p.parallelism, "parallelism", 10, "The maximum number of concurrent pause requests to send to the SA (default: 10)")
    35  }
    36  
    37  func (p *subcommandPauseIdentifier) Run(ctx context.Context, a *admin) error {
    38  	if p.batchFile == "" {
    39  		return errors.New("the -batch-file flag is required")
    40  	}
    41  
    42  	idents, err := a.readPausedAccountFile(p.batchFile)
    43  	if err != nil {
    44  		return err
    45  	}
    46  
    47  	_, err = a.pauseIdentifiers(ctx, idents, p.parallelism)
    48  	if err != nil {
    49  		return err
    50  	}
    51  
    52  	return nil
    53  }
    54  
    55  // pauseIdentifiers concurrently pauses identifiers for each account using up to
    56  // `parallelism` workers. It returns all pause responses and any accumulated
    57  // errors.
    58  func (a *admin) pauseIdentifiers(ctx context.Context, entries []pauseCSVData, parallelism uint) ([]*sapb.PauseIdentifiersResponse, error) {
    59  	if len(entries) <= 0 {
    60  		return nil, errors.New("cannot pause identifiers because no pauseData was sent")
    61  	}
    62  
    63  	accountToIdents := make(map[int64][]*corepb.Identifier)
    64  	for _, entry := range entries {
    65  		accountToIdents[entry.accountID] = append(accountToIdents[entry.accountID], &corepb.Identifier{
    66  			Type:  string(entry.identifierType),
    67  			Value: entry.identifierValue,
    68  		})
    69  	}
    70  
    71  	var errCount atomic.Uint64
    72  	respChan := make(chan *sapb.PauseIdentifiersResponse, len(accountToIdents))
    73  	work := make(chan struct {
    74  		accountID int64
    75  		idents    []*corepb.Identifier
    76  	}, parallelism)
    77  
    78  	var wg sync.WaitGroup
    79  	for range parallelism {
    80  		wg.Go(func() {
    81  			for data := range work {
    82  				response, err := a.sac.PauseIdentifiers(ctx, &sapb.PauseRequest{
    83  					RegistrationID: data.accountID,
    84  					Identifiers:    data.idents,
    85  				})
    86  				if err != nil {
    87  					errCount.Add(1)
    88  					a.log.Errf("error pausing identifier(s) %q for account %d: %v", data.idents, data.accountID, err)
    89  				} else {
    90  					respChan <- response
    91  				}
    92  			}
    93  		})
    94  	}
    95  
    96  	for accountID, idents := range accountToIdents {
    97  		work <- struct {
    98  			accountID int64
    99  			idents    []*corepb.Identifier
   100  		}{accountID, idents}
   101  	}
   102  	close(work)
   103  	wg.Wait()
   104  	close(respChan)
   105  
   106  	var responses []*sapb.PauseIdentifiersResponse
   107  	for response := range respChan {
   108  		responses = append(responses, response)
   109  	}
   110  
   111  	if errCount.Load() > 0 {
   112  		return responses, fmt.Errorf("encountered %d errors while pausing identifiers; see logs above for details", errCount.Load())
   113  	}
   114  
   115  	return responses, nil
   116  }
   117  
   118  // pauseCSVData contains a golang representation of the data loaded in from a
   119  // CSV file for pausing.
   120  type pauseCSVData struct {
   121  	accountID       int64
   122  	identifierType  identifier.IdentifierType
   123  	identifierValue string
   124  }
   125  
   126  // readPausedAccountFile parses the contents of a CSV into a slice of
   127  // `pauseCSVData` objects and returns it or an error. It will skip malformed
   128  // lines and continue processing until either the end of file marker is detected
   129  // or other read error.
   130  func (a *admin) readPausedAccountFile(filePath string) ([]pauseCSVData, error) {
   131  	fp, err := os.Open(filePath)
   132  	if err != nil {
   133  		return nil, fmt.Errorf("opening paused account data file: %w", err)
   134  	}
   135  	defer fp.Close()
   136  
   137  	reader := csv.NewReader(fp)
   138  
   139  	// identifierValue can have 1 or more entries
   140  	reader.FieldsPerRecord = -1
   141  	reader.TrimLeadingSpace = true
   142  
   143  	var parsedRecords []pauseCSVData
   144  	lineCounter := 0
   145  
   146  	// Process contents of the CSV file
   147  	for {
   148  		record, err := reader.Read()
   149  		if errors.Is(err, io.EOF) {
   150  			break
   151  		} else if err != nil {
   152  			return nil, err
   153  		}
   154  
   155  		lineCounter++
   156  
   157  		// We should have strictly 3 fields, note that just commas is considered
   158  		// a valid CSV line.
   159  		if len(record) != 3 {
   160  			a.log.Infof("skipping: malformed line %d, should contain exactly 3 fields\n", lineCounter)
   161  			continue
   162  		}
   163  
   164  		recordID := record[0]
   165  		accountID, err := strconv.ParseInt(recordID, 10, 64)
   166  		if err != nil || accountID == 0 {
   167  			a.log.Infof("skipping: malformed accountID entry on line %d\n", lineCounter)
   168  			continue
   169  		}
   170  
   171  		// Ensure that an identifier type is present, otherwise skip the line.
   172  		if len(record[1]) == 0 {
   173  			a.log.Infof("skipping: malformed identifierType entry on line %d\n", lineCounter)
   174  			continue
   175  		}
   176  
   177  		if len(record[2]) == 0 {
   178  			a.log.Infof("skipping: malformed identifierValue entry on line %d\n", lineCounter)
   179  			continue
   180  		}
   181  
   182  		parsedRecord := pauseCSVData{
   183  			accountID:       accountID,
   184  			identifierType:  identifier.IdentifierType(record[1]),
   185  			identifierValue: record[2],
   186  		}
   187  		parsedRecords = append(parsedRecords, parsedRecord)
   188  	}
   189  	a.log.Infof("detected %d valid record(s) from input file\n", len(parsedRecords))
   190  
   191  	return parsedRecords, nil
   192  }