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 }