Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

admin: Add pause-identifier and unpause-account subcommands #7668

Merged
merged 16 commits into from
Aug 22, 2024
8 changes: 5 additions & 3 deletions cmd/admin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ func main() {

// This is the registry of all subcommands that the admin tool can run.
subcommands := map[string]subcommand{
"revoke-cert": &subcommandRevokeCert{},
"block-key": &subcommandBlockKey{},
"update-email": &subcommandUpdateEmail{},
"revoke-cert": &subcommandRevokeCert{},
"block-key": &subcommandBlockKey{},
"update-email": &subcommandUpdateEmail{},
"pause-identifier": &subcommandPauseIdentifier{},
"unpause-account": &subcommandUnpauseAccount{},
}

defaultUsage := flag.Usage
Expand Down
148 changes: 148 additions & 0 deletions cmd/admin/pause_identifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package main

import (
"context"
"encoding/csv"
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"

"github.com/letsencrypt/boulder/identifier"
sapb "github.com/letsencrypt/boulder/sa/proto"
)

// subcommandPauseIdentifier encapsulates the "admin pause-identifiers" command.
type subcommandPauseIdentifier struct {
batchFile string
}

var _ subcommand = (*subcommandPauseIdentifier)(nil)

func (p *subcommandPauseIdentifier) Desc() string {
return "Administratively pause an account preventing it from attempting certificate issuance"
}

func (p *subcommandPauseIdentifier) Flags(flag *flag.FlagSet) {
flag.StringVar(&p.batchFile, "batch-file", "", "Path to a CSV file containing (account ID, identifier type, list of identifier strings)")
}

func (p *subcommandPauseIdentifier) Run(ctx context.Context, a *admin) error {
if p.batchFile == "" {
return errors.New("the -batch-file flag is required")
}

identifiers, err := a.readPausedAccountFile(p.batchFile)
if err != nil {
return err
}

_, err = a.pauseIdentifiers(ctx, identifiers)
if err != nil {
return err
}

return nil
}

// pauseIdentifiers allows administratively pausing a set of domain names for an
// account. It returns a slice of PauseIdentifiersResponse or an error.
func (a *admin) pauseIdentifiers(ctx context.Context, incoming []pauseCSVData) ([]*sapb.PauseIdentifiersResponse, error) {
if len(incoming) <= 0 {
return nil, errors.New("cannot pause identifiers because no pauseData was sent")
}

var responses []*sapb.PauseIdentifiersResponse
for _, data := range incoming {
req := sapb.PauseRequest{
RegistrationID: data.accountID,
Identifiers: []*sapb.Identifier{
{
Type: string(data.identifierType),
Value: strings.Join(data.identifierValue, ","),
pgporada marked this conversation as resolved.
Show resolved Hide resolved
},
},
}
response, err := a.sac.PauseIdentifiers(ctx, &req)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the PauseIdentifiers gRPC method can take many identifiers as input. Maybe collect rows from the CSV into batches of 100, and then send those in a single request?

Even better, make the batch size controllable with a command line flag that defaults to 100 or so, sort of like the -parallelism flag on other admin subcommands.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am going to skip this for now. This sounds like a good future improvement.

if err != nil {
return nil, err
}
responses = append(responses, response)
}

return responses, nil
}

// pauseCSVData contains a golang representation of the data loaded in from a
// CSV file for pausing.
type pauseCSVData struct {
accountID int64
identifierType identifier.IdentifierType
identifierValue []string
}

// readPausedAccountFile parses the contents of a CSV into a slice of
// `pauseCSVData` objects and returns it or an error. It will skip malformed
// lines and continue processing until either the end of file marker is detected
// or other read error.
func (a *admin) readPausedAccountFile(filePath string) ([]pauseCSVData, error) {
fp, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("opening paused account data file: %w", err)
}
defer fp.Close()

reader := csv.NewReader(fp)

// identifierValue can have 1 or more entries
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true

var parsedRecords []pauseCSVData
lineCounter := 0

// Process contents of the CSV file
for {
record, err := reader.Read()
if errors.Is(err, io.EOF) {
break
} else if err != nil {
return nil, err
}

lineCounter++

// We should have at least 3 fields, note that just commas is considered
// a valid CSV line.
if len(record) < 3 {
a.log.Infof("skipping: malformed identifierValue entry on line %d\n", lineCounter)
continue
}

recordID := record[0]
accountID, err := strconv.ParseInt(recordID, 10, 64)
if err != nil || accountID == 0 {
a.log.Infof("skipping: malformed accountID entry on line %d\n", lineCounter)
continue
}

// Ensure that an identifier type is present, otherwise skip the line.
if len(record[1]) == 0 {
a.log.Infof("skipping: malformed identifierType entry on line %d\n", lineCounter)
continue
}

parsedRecord := pauseCSVData{
accountID: accountID,
identifierType: identifier.IdentifierType(record[1]),
identifierValue: record[2:],
}
parsedRecords = append(parsedRecords, parsedRecord)
}
a.log.Infof("detected %d valid record(s) from input file\n", len(parsedRecords))

return parsedRecords, nil
}
104 changes: 104 additions & 0 deletions cmd/admin/pause_identifier_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"context"
"os"
"path"
"strings"
"testing"

blog "github.com/letsencrypt/boulder/log"
sapb "github.com/letsencrypt/boulder/sa/proto"
"github.com/letsencrypt/boulder/test"
"google.golang.org/grpc"
)

// mockSAPaused is a mock which records the PauseRequest it received, and
// returns the number of identifiers as a PauseIdentifiersResponse. It does not
// maintain state of repaused identifiers.
type mockSAPaused struct {
sapb.StorageAuthorityClient
reqs []*sapb.PauseRequest
}

func (msa *mockSAPaused) PauseIdentifiers(ctx context.Context, in *sapb.PauseRequest, _ ...grpc.CallOption) (*sapb.PauseIdentifiersResponse, error) {
msa.reqs = append(msa.reqs, in)

return &sapb.PauseIdentifiersResponse{Paused: int64(len(in.Identifiers))}, nil
}

func TestPausingIdentifiers(t *testing.T) {
pgporada marked this conversation as resolved.
Show resolved Hide resolved
t.Parallel()

testCases := []struct {
name string
data []string
expectedRecords int
expectErr bool
}{
{
name: "No data in file",
data: nil,
expectErr: true,
},
{
name: "valid",
data: []string{"1,dns,example.com"},
expectedRecords: 1,
},
{
name: "valid with duplicates",
data: []string{"1,dns,example.com,example.net", "2,dns,example.org", "1,dns,example.com,example.net", "1,dns,example.com,example.net", "3,dns,example.gov", "3,dns,example.gov"},
expectedRecords: 6,
},
{
name: "invalid just commas",
data: []string{",,,"},
expectErr: true,
},
{
name: "invalid only contains accountID",
data: []string{"1"},
expectErr: true,
},
{
name: "invalid only contains accountID and identifierType",
data: []string{"1,dns"},
expectErr: true,
},
{
name: "invalid missing identifierType",
data: []string{"1,,example.com"},
expectErr: true,
},
{
name: "invalid accountID isnt an int",
data: []string{"blorple"},
expectErr: true,
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
log := blog.NewMock()
a := admin{sac: &mockSAPaused{}, log: log}

csvFile := path.Join(t.TempDir(), path.Base(t.Name()+".csv"))
err := os.WriteFile(csvFile, []byte(strings.Join(testCase.data, "\n")), os.ModePerm)
test.AssertNotError(t, err, "could not write temporary file")

parsedData, err := a.readPausedAccountFile(csvFile)
test.AssertNotError(t, err, "no error expected, but received one")
test.AssertEquals(t, len(parsedData), testCase.expectedRecords)

responses, err := a.pauseIdentifiers(context.TODO(), parsedData)
if testCase.expectErr {
test.AssertError(t, err, "should not have been able to pause identifiers, but did")
} else {
test.AssertNotError(t, err, "could not pause identifiers")
}
test.AssertEquals(t, len(responses), testCase.expectedRecords)
})
}
}
118 changes: 118 additions & 0 deletions cmd/admin/unpause_account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package main

import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
"strconv"

sapb "github.com/letsencrypt/boulder/sa/proto"
"golang.org/x/exp/maps"
)

// subcommandUnpauseAccount encapsulates the "admin unpause-account" command.
type subcommandUnpauseAccount struct {
batchFile string
regID int64
}

var _ subcommand = (*subcommandUnpauseAccount)(nil)

func (u *subcommandUnpauseAccount) Desc() string {
return "Administratively unpause an account to allow certificate issuance attempts"
}

func (u *subcommandUnpauseAccount) Flags(flag *flag.FlagSet) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally optional suggestion: implement a parallelism flag, like some of the other subcommands have.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am going to skip this for now. This sounds like a good future improvement.

flag.StringVar(&u.batchFile, "batch-file", "", "Path to a file containing multiple account IDs where each is separated by a newline")
flag.Int64Var(&u.regID, "account", 0, "A single account ID to unpause")
}

func (u *subcommandUnpauseAccount) Run(ctx context.Context, a *admin) error {
// This is a map of all input-selection flags to whether or not they were set
// to a non-default value. We use this to ensure that exactly one input
// selection flag was given on the command line.
setInputs := map[string]bool{
"-account": u.regID != 0,
"-batch-file": u.batchFile != "",
}
maps.DeleteFunc(setInputs, func(_ string, v bool) bool { return !v })
if len(setInputs) == 0 {
return errors.New("at least one input method flag must be specified")
} else if len(setInputs) > 1 {
return fmt.Errorf("more than one input method flag specified: %v", maps.Keys(setInputs))
}

var regIDs []int64
var err error
switch maps.Keys(setInputs)[0] {
case "-account":
regIDs = []int64{u.regID}
case "-batch-file":
regIDs, err = a.readUnpauseAccountFile(u.batchFile)
default:
return errors.New("no recognized input method flag set (this shouldn't happen)")
}
if err != nil {
return fmt.Errorf("collecting serials to revoke: %w", err)
}

_, err = a.unpauseAccounts(ctx, regIDs)
if err != nil {
return err
}

return nil
}

// unpauseAccount allows administratively unpausing all identifiers for an
// account. Returns a slice of int64 which is counter of unpaused accounts or an
// error.
func (a *admin) unpauseAccounts(ctx context.Context, regIDs []int64) ([]int64, error) {
var count []int64
if len(regIDs) <= 0 {
return count, errors.New("cannot unpause accounts because no pauseData was sent")
}

for _, regID := range regIDs {
response, err := a.sac.UnpauseAccount(ctx, &sapb.RegistrationID{Id: regID})
if err != nil {
return count, err
}
count = append(count, response.Count)
}

return count, nil
}

// readUnpauseAccountFile parses the contents of a file containing one account
// ID per into a slice of int64's. It will skip malformed records and continue
// processing until the end of file marker.
func (a *admin) readUnpauseAccountFile(filePath string) ([]int64, error) {
fp, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("opening paused account data file: %w", err)
}
defer fp.Close()

var unpauseAccounts []int64
lineCounter := 0
scanner := bufio.NewScanner(fp)
for scanner.Scan() {
lineCounter++
regID, err := strconv.ParseInt(scanner.Text(), 10, 64)
if err != nil {
a.log.Infof("skipping: malformed account ID entry on line %d\n", lineCounter)
continue
}
unpauseAccounts = append(unpauseAccounts, regID)
}

if err := scanner.Err(); err != nil {
return nil, scanner.Err()
}

return unpauseAccounts, nil
}
Loading
Loading