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

     1  package main
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"errors"
     7  	"flag"
     8  	"fmt"
     9  	"os"
    10  	"slices"
    11  	"strconv"
    12  	"sync"
    13  	"sync/atomic"
    14  
    15  	sapb "github.com/letsencrypt/boulder/sa/proto"
    16  	"github.com/letsencrypt/boulder/unpause"
    17  )
    18  
    19  // subcommandUnpauseAccount encapsulates the "admin unpause-account" command.
    20  type subcommandUnpauseAccount struct {
    21  	accountID   int64
    22  	batchFile   string
    23  	parallelism uint
    24  }
    25  
    26  var _ subcommand = (*subcommandUnpauseAccount)(nil)
    27  
    28  func (u *subcommandUnpauseAccount) Desc() string {
    29  	return "Administratively unpause an account to allow certificate issuance attempts"
    30  }
    31  
    32  func (u *subcommandUnpauseAccount) Flags(flag *flag.FlagSet) {
    33  	flag.Int64Var(&u.accountID, "account", 0, "A single account ID to unpause")
    34  	flag.StringVar(&u.batchFile, "batch-file", "", "Path to a file containing multiple account IDs where each is separated by a newline")
    35  	flag.UintVar(&u.parallelism, "parallelism", 10, "The maximum number of concurrent unpause requests to send to the SA (default: 10)")
    36  }
    37  
    38  func (u *subcommandUnpauseAccount) Run(ctx context.Context, a *admin) error {
    39  	// This is a map of all input-selection flags to whether or not they were set
    40  	// to a non-default value. We use this to ensure that exactly one input
    41  	// selection flag was given on the command line.
    42  	setInputs := map[string]bool{
    43  		"-account":    u.accountID != 0,
    44  		"-batch-file": u.batchFile != "",
    45  	}
    46  	activeFlag, err := findActiveInputMethodFlag(setInputs)
    47  	if err != nil {
    48  		return err
    49  	}
    50  
    51  	var regIDs []int64
    52  	switch activeFlag {
    53  	case "-account":
    54  		regIDs = []int64{u.accountID}
    55  	case "-batch-file":
    56  		regIDs, err = a.readUnpauseAccountFile(u.batchFile)
    57  	default:
    58  		return errors.New("no recognized input method flag set (this shouldn't happen)")
    59  	}
    60  	if err != nil {
    61  		return fmt.Errorf("collecting serials to revoke: %w", err)
    62  	}
    63  
    64  	_, err = a.unpauseAccounts(ctx, regIDs, u.parallelism)
    65  	if err != nil {
    66  		return err
    67  	}
    68  
    69  	return nil
    70  }
    71  
    72  type unpauseCount struct {
    73  	accountID int64
    74  	count     int64
    75  }
    76  
    77  // unpauseAccount concurrently unpauses all identifiers for each account using
    78  // up to `parallelism` workers. It returns a count of the number of identifiers
    79  // unpaused for each account and any accumulated errors.
    80  func (a *admin) unpauseAccounts(ctx context.Context, accountIDs []int64, parallelism uint) ([]unpauseCount, error) {
    81  	if len(accountIDs) <= 0 {
    82  		return nil, errors.New("no account IDs provided for unpausing")
    83  	}
    84  	slices.Sort(accountIDs)
    85  	accountIDs = slices.Compact(accountIDs)
    86  
    87  	countChan := make(chan unpauseCount, len(accountIDs))
    88  	work := make(chan int64)
    89  
    90  	var wg sync.WaitGroup
    91  	var errCount atomic.Uint64
    92  	for range parallelism {
    93  		wg.Go(func() {
    94  			for accountID := range work {
    95  				totalCount := int64(0)
    96  				for {
    97  					response, err := a.sac.UnpauseAccount(ctx, &sapb.RegistrationID{Id: accountID})
    98  					if err != nil {
    99  						errCount.Add(1)
   100  						a.log.Errf("error unpausing accountID %d: %v", accountID, err)
   101  						break
   102  					}
   103  					totalCount += response.Count
   104  					if response.Count < unpause.RequestLimit {
   105  						// All identifiers have been unpaused.
   106  						break
   107  					}
   108  				}
   109  				countChan <- unpauseCount{accountID: accountID, count: totalCount}
   110  			}
   111  		})
   112  	}
   113  
   114  	go func() {
   115  		for _, accountID := range accountIDs {
   116  			work <- accountID
   117  		}
   118  		close(work)
   119  	}()
   120  
   121  	go func() {
   122  		wg.Wait()
   123  		close(countChan)
   124  	}()
   125  
   126  	var unpauseCounts []unpauseCount
   127  	for count := range countChan {
   128  		unpauseCounts = append(unpauseCounts, count)
   129  	}
   130  
   131  	if errCount.Load() > 0 {
   132  		return unpauseCounts, fmt.Errorf("encountered %d errors while unpausing; see logs above for details", errCount.Load())
   133  	}
   134  
   135  	return unpauseCounts, nil
   136  }
   137  
   138  // readUnpauseAccountFile parses the contents of a file containing one account
   139  // ID per into a slice of int64s. It will skip malformed records and continue
   140  // processing until the end of file marker.
   141  func (a *admin) readUnpauseAccountFile(filePath string) ([]int64, error) {
   142  	fp, err := os.Open(filePath)
   143  	if err != nil {
   144  		return nil, fmt.Errorf("opening paused account data file: %w", err)
   145  	}
   146  	defer fp.Close()
   147  
   148  	var unpauseAccounts []int64
   149  	lineCounter := 0
   150  	scanner := bufio.NewScanner(fp)
   151  	for scanner.Scan() {
   152  		lineCounter++
   153  		regID, err := strconv.ParseInt(scanner.Text(), 10, 64)
   154  		if err != nil {
   155  			a.log.Infof("skipping: malformed account ID entry on line %d\n", lineCounter)
   156  			continue
   157  		}
   158  		unpauseAccounts = append(unpauseAccounts, regID)
   159  	}
   160  
   161  	if err := scanner.Err(); err != nil {
   162  		return nil, scanner.Err()
   163  	}
   164  
   165  	return unpauseAccounts, nil
   166  }