ratelimits: Supporting additions for admin tooling (#8279)

- Export `ValidateLimit()` for use in the admin tool.
- Add utility functions `DumpOverrides()` and
`LoadOverridesByBucketKey()` to dump/load overrides to/from a YAML file.
- Export `Limit` and several of its fields to support calls to
`LoadOverridesByBucketKey()` and `ValidateLimit()`, and to return
results from `DumpOverrides()`.
- Add `BuildBucketKey()`, which builds and validates bucket keys based
on the limit name and provided components.
- Also add a `MarshalYAML()` method to `config.Duration`.

Part of https://github.com/letsencrypt/boulder/issues/8165
This commit is contained in:
Samantha Frank 2025-07-07 17:01:05 -04:00 committed by GitHub
parent c1ce0c83d0
commit 05e631593e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 928 additions and 238 deletions

View File

@ -67,3 +67,8 @@ func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error {
d.Duration = dur d.Duration = dur
return nil return nil
} }
// MarshalYAML returns the string form of the duration, as a string.
func (d Duration) MarshalYAML() (any, error) {
return d.Duration.String(), nil
}

View File

@ -122,6 +122,24 @@ func NewIP(ip netip.Addr) ACMEIdentifier {
} }
} }
// FromString converts a string to an ACMEIdentifier.
func FromString(identStr string) ACMEIdentifier {
ip, err := netip.ParseAddr(identStr)
if err == nil {
return NewIP(ip)
}
return NewDNS(identStr)
}
// FromStringSlice converts a slice of strings to a slice of ACMEIdentifier.
func FromStringSlice(identStrs []string) ACMEIdentifiers {
var idents ACMEIdentifiers
for _, identStr := range identStrs {
idents = append(idents, FromString(identStr))
}
return idents
}
// fromX509 extracts the Subject Alternative Names from a certificate or CSR's fields, and // fromX509 extracts the Subject Alternative Names from a certificate or CSR's fields, and
// returns a slice of ACMEIdentifiers. // returns a slice of ACMEIdentifiers.
func fromX509(commonName string, dnsNames []string, ipAddresses []net.IP) ACMEIdentifiers { func fromX509(commonName string, dnsNames []string, ipAddresses []net.IP) ACMEIdentifiers {

View File

@ -10,7 +10,7 @@ import (
// returns a Decision struct with the result of the decision and the updated // returns a Decision struct with the result of the decision and the updated
// TAT. The cost must be 0 or greater and <= the burst capacity of the limit. // TAT. The cost must be 0 or greater and <= the burst capacity of the limit.
func maybeSpend(clk clock.Clock, txn Transaction, tat time.Time) *Decision { func maybeSpend(clk clock.Clock, txn Transaction, tat time.Time) *Decision {
if txn.cost < 0 || txn.cost > txn.limit.burst { if txn.cost < 0 || txn.cost > txn.limit.Burst {
// The condition above is the union of the conditions checked in Check // The condition above is the union of the conditions checked in Check
// and Spend methods of Limiter. If this panic is reached, it means that // and Spend methods of Limiter. If this panic is reached, it means that
// the caller has introduced a bug. // the caller has introduced a bug.
@ -67,7 +67,7 @@ func maybeSpend(clk clock.Clock, txn Transaction, tat time.Time) *Decision {
// or greater. A cost will only be refunded up to the burst capacity of the // or greater. A cost will only be refunded up to the burst capacity of the
// limit. A partial refund is still considered successful. // limit. A partial refund is still considered successful.
func maybeRefund(clk clock.Clock, txn Transaction, tat time.Time) *Decision { func maybeRefund(clk clock.Clock, txn Transaction, tat time.Time) *Decision {
if txn.cost < 0 || txn.cost > txn.limit.burst { if txn.cost < 0 || txn.cost > txn.limit.Burst {
// The condition above is checked in the Refund method of Limiter. If // The condition above is checked in the Refund method of Limiter. If
// this panic is reached, it means that the caller has introduced a bug. // this panic is reached, it means that the caller has introduced a bug.
panic("invalid cost for maybeRefund") panic("invalid cost for maybeRefund")
@ -80,7 +80,7 @@ func maybeRefund(clk clock.Clock, txn Transaction, tat time.Time) *Decision {
// The TAT is in the past, therefore the bucket is full. // The TAT is in the past, therefore the bucket is full.
return &Decision{ return &Decision{
allowed: false, allowed: false,
remaining: txn.limit.burst, remaining: txn.limit.Burst,
retryIn: time.Duration(0), retryIn: time.Duration(0),
resetIn: time.Duration(0), resetIn: time.Duration(0),
newTAT: tat, newTAT: tat,

View File

@ -12,7 +12,7 @@ import (
func TestDecide(t *testing.T) { func TestDecide(t *testing.T) {
clk := clock.NewFake() clk := clock.NewFake()
limit := &limit{burst: 10, count: 1, period: config.Duration{Duration: time.Second}} limit := &Limit{Burst: 10, Count: 1, Period: config.Duration{Duration: time.Second}}
limit.precompute() limit.precompute()
// Begin by using 1 of our 10 requests. // Begin by using 1 of our 10 requests.
@ -139,7 +139,7 @@ func TestDecide(t *testing.T) {
func TestMaybeRefund(t *testing.T) { func TestMaybeRefund(t *testing.T) {
clk := clock.NewFake() clk := clock.NewFake()
limit := &limit{burst: 10, count: 1, period: config.Duration{Duration: time.Second}} limit := &Limit{Burst: 10, Count: 1, Period: config.Duration{Duration: time.Second}}
limit.precompute() limit.precompute()
// Begin by using 1 of our 10 requests. // Begin by using 1 of our 10 requests.

View File

@ -1,10 +1,13 @@
package ratelimits package ratelimits
import ( import (
"encoding/csv"
"errors" "errors"
"fmt" "fmt"
"net/netip" "net/netip"
"os" "os"
"sort"
"strconv"
"strings" "strings"
"github.com/letsencrypt/boulder/config" "github.com/letsencrypt/boulder/config"
@ -38,26 +41,32 @@ type LimitConfig struct {
type LimitConfigs map[string]*LimitConfig type LimitConfigs map[string]*LimitConfig
// limit defines the configuration for a rate limit or a rate limit override. // Limit defines the configuration for a rate limit or a rate limit override.
// //
// The zero value of this struct is invalid, because some of the fields must // The zero value of this struct is invalid, because some of the fields must be
// be greater than zero. // greater than zero. It and several of its fields are exported to support admin
type limit struct { // tooling used during the migration from overrides.yaml to the overrides
// burst specifies maximum concurrent allowed requests at any given time. It // database table.
type Limit struct {
// Burst specifies maximum concurrent allowed requests at any given time. It
// must be greater than zero. // must be greater than zero.
burst int64 Burst int64
// count is the number of requests allowed per period. It must be greater // Count is the number of requests allowed per period. It must be greater
// than zero. // than zero.
count int64 Count int64
// period is the duration of time in which the count (of requests) is // Period is the duration of time in which the count (of requests) is
// allowed. It must be greater than zero. // allowed. It must be greater than zero.
period config.Duration Period config.Duration
// name is the name of the limit. It must be one of the Name enums defined // Name is the name of the limit. It must be one of the Name enums defined
// in this package. // in this package.
name Name Name Name
// Comment is an optional field that can be used to provide additional
// context for an override. It is not used for default limits.
Comment string
// emissionInterval is the interval, in nanoseconds, at which tokens are // emissionInterval is the interval, in nanoseconds, at which tokens are
// added to a bucket (period / count). This is also the steady-state rate at // added to a bucket (period / count). This is also the steady-state rate at
@ -76,25 +85,25 @@ type limit struct {
} }
// precompute calculates the emissionInterval and burstOffset for the limit. // precompute calculates the emissionInterval and burstOffset for the limit.
func (l *limit) precompute() { func (l *Limit) precompute() {
l.emissionInterval = l.period.Nanoseconds() / l.count l.emissionInterval = l.Period.Nanoseconds() / l.Count
l.burstOffset = l.emissionInterval * l.burst l.burstOffset = l.emissionInterval * l.Burst
} }
func validateLimit(l *limit) error { func ValidateLimit(l *Limit) error {
if l.burst <= 0 { if l.Burst <= 0 {
return fmt.Errorf("invalid burst '%d', must be > 0", l.burst) return fmt.Errorf("invalid burst '%d', must be > 0", l.Burst)
} }
if l.count <= 0 { if l.Count <= 0 {
return fmt.Errorf("invalid count '%d', must be > 0", l.count) return fmt.Errorf("invalid count '%d', must be > 0", l.Count)
} }
if l.period.Duration <= 0 { if l.Period.Duration <= 0 {
return fmt.Errorf("invalid period '%s', must be > 0", l.period) return fmt.Errorf("invalid period '%s', must be > 0", l.Period)
} }
return nil return nil
} }
type limits map[string]*limit type Limits map[string]*Limit
// loadDefaults marshals the defaults YAML file at path into a map of limits. // loadDefaults marshals the defaults YAML file at path into a map of limits.
func loadDefaults(path string) (LimitConfigs, error) { func loadDefaults(path string) (LimitConfigs, error) {
@ -149,9 +158,9 @@ func parseOverrideNameId(key string) (Name, string, error) {
return Unknown, "", fmt.Errorf("empty name in override %q, must be formatted 'name:id'", key) return Unknown, "", fmt.Errorf("empty name in override %q, must be formatted 'name:id'", key)
} }
name, ok := stringToName[nameStr] name, ok := StringToName[nameStr]
if !ok { if !ok {
return Unknown, "", fmt.Errorf("unrecognized name %q in override limit %q, must be one of %v", nameStr, key, limitNames) return Unknown, "", fmt.Errorf("unrecognized name %q in override limit %q, must be one of %v", nameStr, key, LimitNames)
} }
id := nameAndId[1] id := nameAndId[1]
if id == "" { if id == "" {
@ -160,37 +169,52 @@ func parseOverrideNameId(key string) (Name, string, error) {
return name, id, nil return name, id, nil
} }
// parseOverrideNameEnumId is like parseOverrideNameId, but it expects the
// key to be formatted as 'name:id', where 'name' is a Name enum string and 'id'
// is a string identifier. It returns an error if either part is missing or invalid.
func parseOverrideNameEnumId(key string) (Name, string, error) {
if !strings.Contains(key, ":") {
// Avoids a potential panic in strings.SplitN below.
return Unknown, "", fmt.Errorf("invalid override %q, must be formatted 'name:id'", key)
}
nameStrAndId := strings.SplitN(key, ":", 2)
if len(nameStrAndId) != 2 {
return Unknown, "", fmt.Errorf("invalid override %q, must be formatted 'name:id'", key)
}
nameInt, err := strconv.Atoi(nameStrAndId[0])
if err != nil {
return Unknown, "", fmt.Errorf("invalid name %q in override limit %q, must be an integer", nameStrAndId[0], key)
}
name := Name(nameInt)
if !name.isValid() {
return Unknown, "", fmt.Errorf("invalid name %q in override limit %q, must be one of %v", nameStrAndId[0], key, LimitNames)
}
id := nameStrAndId[1]
if id == "" {
return Unknown, "", fmt.Errorf("empty id in override %q, must be formatted 'name:id'", key)
}
return name, id, nil
}
// parseOverrideLimits validates a YAML list of override limits. It must be // parseOverrideLimits validates a YAML list of override limits. It must be
// formatted as a list of maps, where each map has a single key representing the // formatted as a list of maps, where each map has a single key representing the
// limit name and a value that is a map containing the limit fields and an // limit name and a value that is a map containing the limit fields and an
// additional 'ids' field that is a list of ids that this override applies to. // additional 'ids' field that is a list of ids that this override applies to.
func parseOverrideLimits(newOverridesYAML overridesYAML) (limits, error) { func parseOverrideLimits(newOverridesYAML overridesYAML) (Limits, error) {
parsed := make(limits) parsed := make(Limits)
for _, ov := range newOverridesYAML { for _, ov := range newOverridesYAML {
for k, v := range ov { for k, v := range ov {
name, ok := stringToName[k] name, ok := StringToName[k]
if !ok { if !ok {
return nil, fmt.Errorf("unrecognized name %q in override limit, must be one of %v", k, limitNames) return nil, fmt.Errorf("unrecognized name %q in override limit, must be one of %v", k, LimitNames)
}
lim := &limit{
burst: v.Burst,
count: v.Count,
period: v.Period,
name: name,
isOverride: true,
}
lim.precompute()
err := validateLimit(lim)
if err != nil {
return nil, fmt.Errorf("validating override limit %q: %w", k, err)
} }
for _, entry := range v.Ids { for _, entry := range v.Ids {
id := entry.Id id := entry.Id
err = validateIdForName(name, id) err := validateIdForName(name, id)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"validating name %s and id %q for override limit %q: %w", name, id, k, err) "validating name %s and id %q for override limit %q: %w", name, id, k, err)
@ -204,7 +228,7 @@ func parseOverrideLimits(newOverridesYAML overridesYAML) (limits, error) {
// (IPv6) prefixes in CIDR notation. // (IPv6) prefixes in CIDR notation.
ip, err := netip.ParseAddr(id) ip, err := netip.ParseAddr(id)
if err == nil { if err == nil {
prefix, err := coveringPrefix(ip) prefix, err := coveringIPPrefix(name, ip)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"computing prefix for IP address %q: %w", id, err) "computing prefix for IP address %q: %w", id, err)
@ -214,16 +238,22 @@ func parseOverrideLimits(newOverridesYAML overridesYAML) (limits, error) {
case CertificatesPerFQDNSet: case CertificatesPerFQDNSet:
// Compute the hash of a comma-separated list of identifier // Compute the hash of a comma-separated list of identifier
// values. // values.
var idents identifier.ACMEIdentifiers id = fmt.Sprintf("%x", core.HashIdentifiers(identifier.FromStringSlice(strings.Split(id, ","))))
for _, value := range strings.Split(id, ",") { }
ip, err := netip.ParseAddr(value)
if err == nil { lim := &Limit{
idents = append(idents, identifier.NewIP(ip)) Burst: v.Burst,
} else { Count: v.Count,
idents = append(idents, identifier.NewDNS(value)) Period: v.Period,
} Name: name,
} Comment: entry.Comment,
id = fmt.Sprintf("%x", core.HashIdentifiers(idents)) isOverride: true,
}
lim.precompute()
err = ValidateLimit(lim)
if err != nil {
return nil, fmt.Errorf("validating override limit %q: %w", k, err)
} }
parsed[joinWithColon(name.EnumString(), id)] = lim parsed[joinWithColon(name.EnumString(), id)] = lim
@ -234,23 +264,23 @@ func parseOverrideLimits(newOverridesYAML overridesYAML) (limits, error) {
} }
// parseDefaultLimits validates a map of default limits and rekeys it by 'Name'. // parseDefaultLimits validates a map of default limits and rekeys it by 'Name'.
func parseDefaultLimits(newDefaultLimits LimitConfigs) (limits, error) { func parseDefaultLimits(newDefaultLimits LimitConfigs) (Limits, error) {
parsed := make(limits) parsed := make(Limits)
for k, v := range newDefaultLimits { for k, v := range newDefaultLimits {
name, ok := stringToName[k] name, ok := StringToName[k]
if !ok { if !ok {
return nil, fmt.Errorf("unrecognized name %q in default limit, must be one of %v", k, limitNames) return nil, fmt.Errorf("unrecognized name %q in default limit, must be one of %v", k, LimitNames)
} }
lim := &limit{ lim := &Limit{
burst: v.Burst, Burst: v.Burst,
count: v.Count, Count: v.Count,
period: v.Period, Period: v.Period,
name: name, Name: name,
} }
err := validateLimit(lim) err := ValidateLimit(lim)
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing default limit %q: %w", k, err) return nil, fmt.Errorf("parsing default limit %q: %w", k, err)
} }
@ -263,10 +293,10 @@ func parseDefaultLimits(newDefaultLimits LimitConfigs) (limits, error) {
type limitRegistry struct { type limitRegistry struct {
// defaults stores default limits by 'name'. // defaults stores default limits by 'name'.
defaults limits defaults Limits
// overrides stores override limits by 'name:id'. // overrides stores override limits by 'name:id'.
overrides limits overrides Limits
} }
func newLimitRegistryFromFiles(defaults, overrides string) (*limitRegistry, error) { func newLimitRegistryFromFiles(defaults, overrides string) (*limitRegistry, error) {
@ -308,7 +338,7 @@ func newLimitRegistry(defaults LimitConfigs, overrides overridesYAML) (*limitReg
// required, bucketKey is optional. If bucketkey is empty, the default for the // required, bucketKey is optional. If bucketkey is empty, the default for the
// limit specified by name is returned. If no default limit exists for the // limit specified by name is returned. If no default limit exists for the
// specified name, errLimitDisabled is returned. // specified name, errLimitDisabled is returned.
func (l *limitRegistry) getLimit(name Name, bucketKey string) (*limit, error) { func (l *limitRegistry) getLimit(name Name, bucketKey string) (*Limit, error) {
if !name.isValid() { if !name.isValid() {
// This should never happen. Callers should only be specifying the limit // This should never happen. Callers should only be specifying the limit
// Name enums defined in this package. // Name enums defined in this package.
@ -327,3 +357,103 @@ func (l *limitRegistry) getLimit(name Name, bucketKey string) (*limit, error) {
} }
return nil, errLimitDisabled return nil, errLimitDisabled
} }
// LoadOverridesByBucketKey loads the overrides YAML at the supplied path,
// parses it with the existing helpers, and returns the resulting limits map
// keyed by "<name>:<id>". This function is exported to support admin tooling
// used during the migration from overrides.yaml to the overrides database
// table.
func LoadOverridesByBucketKey(path string) (Limits, error) {
ovs, err := loadOverrides(path)
if err != nil {
return nil, err
}
return parseOverrideLimits(ovs)
}
// DumpOverrides writes the provided overrides to CSV at the supplied path. Each
// override is written as a single row, one per ID. Rows are sorted in the
// following order:
// - Name (ascending)
// - Count (descending)
// - Burst (descending)
// - Period (ascending)
// - Comment (ascending)
// - ID (ascending)
//
// This function supports admin tooling that routinely exports the overrides
// table for investigation or auditing.
func DumpOverrides(path string, overrides Limits) error {
type row struct {
name string
id string
count int64
burst int64
period string
comment string
}
var rows []row
for bucketKey, limit := range overrides {
name, id, err := parseOverrideNameEnumId(bucketKey)
if err != nil {
return err
}
rows = append(rows, row{
name: name.String(),
id: id,
count: limit.Count,
burst: limit.Burst,
period: limit.Period.Duration.String(),
comment: limit.Comment,
})
}
sort.Slice(rows, func(i, j int) bool {
// Sort by limit name in ascending order.
if rows[i].name != rows[j].name {
return rows[i].name < rows[j].name
}
// Sort by count in descending order (higher counts first).
if rows[i].count != rows[j].count {
return rows[i].count > rows[j].count
}
// Sort by burst in descending order (higher bursts first).
if rows[i].burst != rows[j].burst {
return rows[i].burst > rows[j].burst
}
// Sort by period in ascending order (shorter durations first).
if rows[i].period != rows[j].period {
return rows[i].period < rows[j].period
}
// Sort by comment in ascending order.
if rows[i].comment != rows[j].comment {
return rows[i].comment < rows[j].comment
}
// Sort by ID in ascending order.
return rows[i].id < rows[j].id
})
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
w := csv.NewWriter(f)
err = w.Write([]string{"name", "id", "count", "burst", "period", "comment"})
if err != nil {
return err
}
for _, r := range rows {
err := w.Write([]string{r.name, r.id, strconv.FormatInt(r.count, 10), strconv.FormatInt(r.burst, 10), r.period, r.comment})
if err != nil {
return err
}
}
w.Flush()
return w.Error()
}

View File

@ -3,6 +3,8 @@ package ratelimits
import ( import (
"net/netip" "net/netip"
"os" "os"
"path/filepath"
"strings"
"testing" "testing"
"time" "time"
@ -15,7 +17,7 @@ import (
// parseDefaultLimits to handle a YAML file. // parseDefaultLimits to handle a YAML file.
// //
// TODO(#7901): Update the tests to test these functions individually. // TODO(#7901): Update the tests to test these functions individually.
func loadAndParseDefaultLimits(path string) (limits, error) { func loadAndParseDefaultLimits(path string) (Limits, error) {
fromFile, err := loadDefaults(path) fromFile, err := loadDefaults(path)
if err != nil { if err != nil {
return nil, err return nil, err
@ -28,7 +30,7 @@ func loadAndParseDefaultLimits(path string) (limits, error) {
// parseOverrideLimits to handle a YAML file. // parseOverrideLimits to handle a YAML file.
// //
// TODO(#7901): Update the tests to test these functions individually. // TODO(#7901): Update the tests to test these functions individually.
func loadAndParseOverrideLimits(path string) (limits, error) { func loadAndParseOverrideLimits(path string) (Limits, error) {
fromFile, err := loadOverrides(path) fromFile, err := loadOverrides(path)
if err != nil { if err != nil {
return nil, err return nil, err
@ -69,17 +71,79 @@ func TestParseOverrideNameId(t *testing.T) {
test.AssertError(t, err, "invalid enum") test.AssertError(t, err, "invalid enum")
} }
func TestParseOverrideNameEnumId(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantLimit Name
wantId string
expectError bool
}{
{
name: "valid IPv4 address",
input: NewRegistrationsPerIPAddress.EnumString() + ":10.0.0.1",
wantLimit: NewRegistrationsPerIPAddress,
wantId: "10.0.0.1",
expectError: false,
},
{
name: "valid IPv6 address range",
input: NewRegistrationsPerIPv6Range.EnumString() + ":2001:0db8:0000::/48",
wantLimit: NewRegistrationsPerIPv6Range,
wantId: "2001:0db8:0000::/48",
expectError: false,
},
{
name: "missing colon",
input: NewRegistrationsPerIPAddress.EnumString() + "10.0.0.1",
expectError: true,
},
{
name: "empty string",
input: "",
expectError: true,
},
{
name: "only a colon",
input: NewRegistrationsPerIPAddress.EnumString() + ":",
expectError: true,
},
{
name: "invalid enum",
input: "lol:noexist",
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
limit, id, err := parseOverrideNameEnumId(tc.input)
if tc.expectError {
if err == nil {
t.Errorf("expected error for input %q, but got none", tc.input)
}
} else {
test.AssertNotError(t, err, tc.name)
test.AssertEquals(t, limit, tc.wantLimit)
test.AssertEquals(t, id, tc.wantId)
}
})
}
}
func TestValidateLimit(t *testing.T) { func TestValidateLimit(t *testing.T) {
err := validateLimit(&limit{burst: 1, count: 1, period: config.Duration{Duration: time.Second}}) err := ValidateLimit(&Limit{Burst: 1, Count: 1, Period: config.Duration{Duration: time.Second}})
test.AssertNotError(t, err, "valid limit") test.AssertNotError(t, err, "valid limit")
// All of the following are invalid. // All of the following are invalid.
for _, l := range []*limit{ for _, l := range []*Limit{
{burst: 0, count: 1, period: config.Duration{Duration: time.Second}}, {Burst: 0, Count: 1, Period: config.Duration{Duration: time.Second}},
{burst: 1, count: 0, period: config.Duration{Duration: time.Second}}, {Burst: 1, Count: 0, Period: config.Duration{Duration: time.Second}},
{burst: 1, count: 1, period: config.Duration{Duration: 0}}, {Burst: 1, Count: 1, Period: config.Duration{Duration: 0}},
} { } {
err = validateLimit(l) err = ValidateLimit(l)
test.AssertError(t, err, "limit should be invalid") test.AssertError(t, err, "limit should be invalid")
} }
} }
@ -89,29 +153,29 @@ func TestLoadAndParseOverrideLimits(t *testing.T) {
l, err := loadAndParseOverrideLimits("testdata/working_override.yml") l, err := loadAndParseOverrideLimits("testdata/working_override.yml")
test.AssertNotError(t, err, "valid single override limit") test.AssertNotError(t, err, "valid single override limit")
expectKey := joinWithColon(NewRegistrationsPerIPAddress.EnumString(), "64.112.117.1") expectKey := joinWithColon(NewRegistrationsPerIPAddress.EnumString(), "64.112.117.1")
test.AssertEquals(t, l[expectKey].burst, int64(40)) test.AssertEquals(t, l[expectKey].Burst, int64(40))
test.AssertEquals(t, l[expectKey].count, int64(40)) test.AssertEquals(t, l[expectKey].Count, int64(40))
test.AssertEquals(t, l[expectKey].period.Duration, time.Second) test.AssertEquals(t, l[expectKey].Period.Duration, time.Second)
// Load single valid override limit with a 'domainOrCIDR' Id. // Load single valid override limit with a 'domainOrCIDR' Id.
l, err = loadAndParseOverrideLimits("testdata/working_override_regid_domainorcidr.yml") l, err = loadAndParseOverrideLimits("testdata/working_override_regid_domainorcidr.yml")
test.AssertNotError(t, err, "valid single override limit with Id of regId:domainOrCIDR") test.AssertNotError(t, err, "valid single override limit with Id of regId:domainOrCIDR")
expectKey = joinWithColon(CertificatesPerDomain.EnumString(), "example.com") expectKey = joinWithColon(CertificatesPerDomain.EnumString(), "example.com")
test.AssertEquals(t, l[expectKey].burst, int64(40)) test.AssertEquals(t, l[expectKey].Burst, int64(40))
test.AssertEquals(t, l[expectKey].count, int64(40)) test.AssertEquals(t, l[expectKey].Count, int64(40))
test.AssertEquals(t, l[expectKey].period.Duration, time.Second) test.AssertEquals(t, l[expectKey].Period.Duration, time.Second)
// Load multiple valid override limits with 'regId' Ids. // Load multiple valid override limits with 'regId' Ids.
l, err = loadAndParseOverrideLimits("testdata/working_overrides.yml") l, err = loadAndParseOverrideLimits("testdata/working_overrides.yml")
test.AssertNotError(t, err, "multiple valid override limits") test.AssertNotError(t, err, "multiple valid override limits")
expectKey1 := joinWithColon(NewRegistrationsPerIPAddress.EnumString(), "64.112.117.1") expectKey1 := joinWithColon(NewRegistrationsPerIPAddress.EnumString(), "64.112.117.1")
test.AssertEquals(t, l[expectKey1].burst, int64(40)) test.AssertEquals(t, l[expectKey1].Burst, int64(40))
test.AssertEquals(t, l[expectKey1].count, int64(40)) test.AssertEquals(t, l[expectKey1].Count, int64(40))
test.AssertEquals(t, l[expectKey1].period.Duration, time.Second) test.AssertEquals(t, l[expectKey1].Period.Duration, time.Second)
expectKey2 := joinWithColon(NewRegistrationsPerIPv6Range.EnumString(), "2602:80a:6000::/48") expectKey2 := joinWithColon(NewRegistrationsPerIPv6Range.EnumString(), "2602:80a:6000::/48")
test.AssertEquals(t, l[expectKey2].burst, int64(50)) test.AssertEquals(t, l[expectKey2].Burst, int64(50))
test.AssertEquals(t, l[expectKey2].count, int64(50)) test.AssertEquals(t, l[expectKey2].Count, int64(50))
test.AssertEquals(t, l[expectKey2].period.Duration, time.Second*2) test.AssertEquals(t, l[expectKey2].Period.Duration, time.Second*2)
// Load multiple valid override limits with 'fqdnSet' Ids, as follows: // Load multiple valid override limits with 'fqdnSet' Ids, as follows:
// - CertificatesPerFQDNSet:example.com // - CertificatesPerFQDNSet:example.com
@ -128,18 +192,18 @@ func TestLoadAndParseOverrideLimits(t *testing.T) {
l, err = loadAndParseOverrideLimits("testdata/working_overrides_regid_fqdnset.yml") l, err = loadAndParseOverrideLimits("testdata/working_overrides_regid_fqdnset.yml")
test.AssertNotError(t, err, "multiple valid override limits with 'fqdnSet' Ids") test.AssertNotError(t, err, "multiple valid override limits with 'fqdnSet' Ids")
test.AssertEquals(t, l[entryKey1].burst, int64(40)) test.AssertEquals(t, l[entryKey1].Burst, int64(40))
test.AssertEquals(t, l[entryKey1].count, int64(40)) test.AssertEquals(t, l[entryKey1].Count, int64(40))
test.AssertEquals(t, l[entryKey1].period.Duration, time.Second) test.AssertEquals(t, l[entryKey1].Period.Duration, time.Second)
test.AssertEquals(t, l[entryKey2].burst, int64(50)) test.AssertEquals(t, l[entryKey2].Burst, int64(50))
test.AssertEquals(t, l[entryKey2].count, int64(50)) test.AssertEquals(t, l[entryKey2].Count, int64(50))
test.AssertEquals(t, l[entryKey2].period.Duration, time.Second*2) test.AssertEquals(t, l[entryKey2].Period.Duration, time.Second*2)
test.AssertEquals(t, l[entryKey3].burst, int64(60)) test.AssertEquals(t, l[entryKey3].Burst, int64(60))
test.AssertEquals(t, l[entryKey3].count, int64(60)) test.AssertEquals(t, l[entryKey3].Count, int64(60))
test.AssertEquals(t, l[entryKey3].period.Duration, time.Second*3) test.AssertEquals(t, l[entryKey3].Period.Duration, time.Second*3)
test.AssertEquals(t, l[entryKey4].burst, int64(60)) test.AssertEquals(t, l[entryKey4].Burst, int64(60))
test.AssertEquals(t, l[entryKey4].count, int64(60)) test.AssertEquals(t, l[entryKey4].Count, int64(60))
test.AssertEquals(t, l[entryKey4].period.Duration, time.Second*4) test.AssertEquals(t, l[entryKey4].Period.Duration, time.Second*4)
// Path is empty string. // Path is empty string.
_, err = loadAndParseOverrideLimits("") _, err = loadAndParseOverrideLimits("")
@ -186,19 +250,19 @@ func TestLoadAndParseDefaultLimits(t *testing.T) {
// Load a single valid default limit. // Load a single valid default limit.
l, err := loadAndParseDefaultLimits("testdata/working_default.yml") l, err := loadAndParseDefaultLimits("testdata/working_default.yml")
test.AssertNotError(t, err, "valid single default limit") test.AssertNotError(t, err, "valid single default limit")
test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].burst, int64(20)) test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Burst, int64(20))
test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].count, int64(20)) test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Count, int64(20))
test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].period.Duration, time.Second) test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Period.Duration, time.Second)
// Load multiple valid default limits. // Load multiple valid default limits.
l, err = loadAndParseDefaultLimits("testdata/working_defaults.yml") l, err = loadAndParseDefaultLimits("testdata/working_defaults.yml")
test.AssertNotError(t, err, "multiple valid default limits") test.AssertNotError(t, err, "multiple valid default limits")
test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].burst, int64(20)) test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Burst, int64(20))
test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].count, int64(20)) test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Count, int64(20))
test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].period.Duration, time.Second) test.AssertEquals(t, l[NewRegistrationsPerIPAddress.EnumString()].Period.Duration, time.Second)
test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].burst, int64(30)) test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].Burst, int64(30))
test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].count, int64(30)) test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].Count, int64(30))
test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].period.Duration, time.Second*2) test.AssertEquals(t, l[NewRegistrationsPerIPv6Range.EnumString()].Period.Duration, time.Second*2)
// Path is empty string. // Path is empty string.
_, err = loadAndParseDefaultLimits("") _, err = loadAndParseDefaultLimits("")
@ -230,3 +294,146 @@ func TestLoadAndParseDefaultLimits(t *testing.T) {
test.AssertError(t, err, "multiple default limits, one is bad") test.AssertError(t, err, "multiple default limits, one is bad")
test.Assert(t, !os.IsNotExist(err), "test file should exist") test.Assert(t, !os.IsNotExist(err), "test file should exist")
} }
func TestLoadAndDumpOverrides(t *testing.T) {
t.Parallel()
input := `
- CertificatesPerDomain:
burst: 5000
count: 5000
period: 168h0m0s
ids:
- id: example.com
comment: IN-10057
- id: example.net
comment: IN-10057
- CertificatesPerDomain:
burst: 300
count: 300
period: 168h0m0s
ids:
- id: example.org
comment: IN-10057
- CertificatesPerDomainPerAccount:
burst: 12000
count: 12000
period: 168h0m0s
ids:
- id: "123456789"
comment: Affluent (IN-8322)
- CertificatesPerDomainPerAccount:
burst: 6000
count: 6000
period: 168h0m0s
ids:
- id: "543219876"
comment: Affluent (IN-8322)
- id: "987654321"
comment: Affluent (IN-8322)
- CertificatesPerFQDNSet:
burst: 50
count: 50
period: 168h0m0s
ids:
- id: example.co.uk,example.cn
comment: IN-6843
- CertificatesPerFQDNSet:
burst: 24
count: 24
period: 168h0m0s
ids:
- id: example.org,example.com,example.net
comment: IN-6006
- FailedAuthorizationsPerDomainPerAccount:
burst: 250
count: 250
period: 1h0m0s
ids:
- id: "123456789"
comment: Digital Lake (IN-6736)
- FailedAuthorizationsPerDomainPerAccount:
burst: 50
count: 50
period: 1h0m0s
ids:
- id: "987654321"
comment: Digital Lake (IN-6856)
- FailedAuthorizationsPerDomainPerAccount:
burst: 10
count: 10
period: 1h0m0s
ids:
- id: "543219876"
comment: Big Mart (IN-6949)
- NewOrdersPerAccount:
burst: 3000
count: 3000
period: 3h0m0s
ids:
- id: "123456789"
comment: Galaxy Hoster (IN-8180)
- NewOrdersPerAccount:
burst: 1000
count: 1000
period: 3h0m0s
ids:
- id: "543219876"
comment: Big Mart (IN-8180)
- id: "987654321"
comment: Buy More (IN-10057)
- NewRegistrationsPerIPAddress:
burst: 100000
count: 100000
period: 3h0m0s
ids:
- id: 2600:1f1c:5e0:e702:ca06:d2a3:c7ce:a02e
comment: example.org IN-2395
- id: 55.66.77.88
comment: example.org IN-2395
- NewRegistrationsPerIPAddress:
burst: 200
count: 200
period: 3h0m0s
ids:
- id: 11.22.33.44
comment: example.net (IN-1583)`
expectCSV := `
name,id,count,burst,period,comment
CertificatesPerDomain,example.com,5000,5000,168h0m0s,IN-10057
CertificatesPerDomain,example.net,5000,5000,168h0m0s,IN-10057
CertificatesPerDomain,example.org,300,300,168h0m0s,IN-10057
CertificatesPerDomainPerAccount,123456789,12000,12000,168h0m0s,Affluent (IN-8322)
CertificatesPerDomainPerAccount,543219876,6000,6000,168h0m0s,Affluent (IN-8322)
CertificatesPerDomainPerAccount,987654321,6000,6000,168h0m0s,Affluent (IN-8322)
CertificatesPerFQDNSet,7c956936126b492845ddb48f4d220034509e7c0ad54ed2c1ba2650406846d9c3,50,50,168h0m0s,IN-6843
CertificatesPerFQDNSet,394e82811f52e2da38b970afdb21c9bc9af81060939c690183c00fce37408738,24,24,168h0m0s,IN-6006
FailedAuthorizationsPerDomainPerAccount,123456789,250,250,1h0m0s,Digital Lake (IN-6736)
FailedAuthorizationsPerDomainPerAccount,987654321,50,50,1h0m0s,Digital Lake (IN-6856)
FailedAuthorizationsPerDomainPerAccount,543219876,10,10,1h0m0s,Big Mart (IN-6949)
NewOrdersPerAccount,123456789,3000,3000,3h0m0s,Galaxy Hoster (IN-8180)
NewOrdersPerAccount,543219876,1000,1000,3h0m0s,Big Mart (IN-8180)
NewOrdersPerAccount,987654321,1000,1000,3h0m0s,Buy More (IN-10057)
NewRegistrationsPerIPAddress,2600:1f1c:5e0:e702:ca06:d2a3:c7ce:a02e,100000,100000,3h0m0s,example.org IN-2395
NewRegistrationsPerIPAddress,55.66.77.88,100000,100000,3h0m0s,example.org IN-2395
NewRegistrationsPerIPAddress,11.22.33.44,200,200,3h0m0s,example.net (IN-1583)
`
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "overrides.yaml")
err := os.WriteFile(tempFile, []byte(input), 0644)
test.AssertNotError(t, err, "writing temp overrides.yaml")
original, err := LoadOverridesByBucketKey(tempFile)
test.AssertNotError(t, err, "loading overrides")
test.Assert(t, len(original) > 0, "expected at least one override loaded")
dumpFile := filepath.Join(tempDir, "dumped.yaml")
err = DumpOverrides(dumpFile, original)
test.AssertNotError(t, err, "dumping overrides")
dumped, err := os.ReadFile(dumpFile)
test.AssertNotError(t, err, "reading dumped overrides file")
test.AssertEquals(t, strings.TrimLeft(string(dumped), "\n"), strings.TrimLeft(expectCSV, "\n"))
}

View File

@ -104,13 +104,13 @@ func (d *Decision) Result(now time.Time) error {
// There is no case for FailedAuthorizationsForPausingPerDomainPerAccount // There is no case for FailedAuthorizationsForPausingPerDomainPerAccount
// because the RA will pause clients who exceed that ratelimit. // because the RA will pause clients who exceed that ratelimit.
switch d.transaction.limit.name { switch d.transaction.limit.Name {
case NewRegistrationsPerIPAddress: case NewRegistrationsPerIPAddress:
return berrors.RegistrationsPerIPAddressError( return berrors.RegistrationsPerIPAddressError(
retryAfter, retryAfter,
"too many new registrations (%d) from this IP address in the last %s, retry after %s", "too many new registrations (%d) from this IP address in the last %s, retry after %s",
d.transaction.limit.burst, d.transaction.limit.Burst,
d.transaction.limit.period.Duration, d.transaction.limit.Period.Duration,
retryAfterTs, retryAfterTs,
) )
@ -118,16 +118,16 @@ func (d *Decision) Result(now time.Time) error {
return berrors.RegistrationsPerIPv6RangeError( return berrors.RegistrationsPerIPv6RangeError(
retryAfter, retryAfter,
"too many new registrations (%d) from this /48 subnet of IPv6 addresses in the last %s, retry after %s", "too many new registrations (%d) from this /48 subnet of IPv6 addresses in the last %s, retry after %s",
d.transaction.limit.burst, d.transaction.limit.Burst,
d.transaction.limit.period.Duration, d.transaction.limit.Period.Duration,
retryAfterTs, retryAfterTs,
) )
case NewOrdersPerAccount: case NewOrdersPerAccount:
return berrors.NewOrdersPerAccountError( return berrors.NewOrdersPerAccountError(
retryAfter, retryAfter,
"too many new orders (%d) from this account in the last %s, retry after %s", "too many new orders (%d) from this account in the last %s, retry after %s",
d.transaction.limit.burst, d.transaction.limit.Burst,
d.transaction.limit.period.Duration, d.transaction.limit.Period.Duration,
retryAfterTs, retryAfterTs,
) )
@ -141,9 +141,9 @@ func (d *Decision) Result(now time.Time) error {
return berrors.FailedAuthorizationsPerDomainPerAccountError( return berrors.FailedAuthorizationsPerDomainPerAccountError(
retryAfter, retryAfter,
"too many failed authorizations (%d) for %q in the last %s, retry after %s", "too many failed authorizations (%d) for %q in the last %s, retry after %s",
d.transaction.limit.burst, d.transaction.limit.Burst,
identValue, identValue,
d.transaction.limit.period.Duration, d.transaction.limit.Period.Duration,
retryAfterTs, retryAfterTs,
) )
@ -157,9 +157,9 @@ func (d *Decision) Result(now time.Time) error {
return berrors.CertificatesPerDomainError( return berrors.CertificatesPerDomainError(
retryAfter, retryAfter,
"too many certificates (%d) already issued for %q in the last %s, retry after %s", "too many certificates (%d) already issued for %q in the last %s, retry after %s",
d.transaction.limit.burst, d.transaction.limit.Burst,
domainOrCIDR, domainOrCIDR,
d.transaction.limit.period.Duration, d.transaction.limit.Period.Duration,
retryAfterTs, retryAfterTs,
) )
@ -167,8 +167,8 @@ func (d *Decision) Result(now time.Time) error {
return berrors.CertificatesPerFQDNSetError( return berrors.CertificatesPerFQDNSetError(
retryAfter, retryAfter,
"too many certificates (%d) already issued for this exact set of identifiers in the last %s, retry after %s", "too many certificates (%d) already issued for this exact set of identifiers in the last %s, retry after %s",
d.transaction.limit.burst, d.transaction.limit.Burst,
d.transaction.limit.period.Duration, d.transaction.limit.Period.Duration,
retryAfterTs, retryAfterTs,
) )
@ -346,7 +346,7 @@ func (l *Limiter) BatchSpend(ctx context.Context, txns []Transaction) (*Decision
totalLatency := l.clk.Since(start) totalLatency := l.clk.Since(start)
perTxnLatency := totalLatency / time.Duration(len(txnOutcomes)) perTxnLatency := totalLatency / time.Duration(len(txnOutcomes))
for txn, outcome := range txnOutcomes { for txn, outcome := range txnOutcomes {
l.spendLatency.WithLabelValues(txn.limit.name.String(), outcome).Observe(perTxnLatency.Seconds()) l.spendLatency.WithLabelValues(txn.limit.Name.String(), outcome).Observe(perTxnLatency.Seconds())
} }
return batchDecision, nil return batchDecision, nil
} }

View File

@ -464,10 +464,10 @@ func TestRateLimitError(t *testing.T) {
allowed: false, allowed: false,
retryIn: 5 * time.Second, retryIn: 5 * time.Second,
transaction: Transaction{ transaction: Transaction{
limit: &limit{ limit: &Limit{
name: NewRegistrationsPerIPAddress, Name: NewRegistrationsPerIPAddress,
burst: 10, Burst: 10,
period: config.Duration{Duration: time.Hour}, Period: config.Duration{Duration: time.Hour},
}, },
}, },
}, },
@ -480,10 +480,10 @@ func TestRateLimitError(t *testing.T) {
allowed: false, allowed: false,
retryIn: 10 * time.Second, retryIn: 10 * time.Second,
transaction: Transaction{ transaction: Transaction{
limit: &limit{ limit: &Limit{
name: NewRegistrationsPerIPv6Range, Name: NewRegistrationsPerIPv6Range,
burst: 5, Burst: 5,
period: config.Duration{Duration: time.Hour}, Period: config.Duration{Duration: time.Hour},
}, },
}, },
}, },
@ -496,10 +496,10 @@ func TestRateLimitError(t *testing.T) {
allowed: false, allowed: false,
retryIn: 10 * time.Second, retryIn: 10 * time.Second,
transaction: Transaction{ transaction: Transaction{
limit: &limit{ limit: &Limit{
name: NewOrdersPerAccount, Name: NewOrdersPerAccount,
burst: 2, Burst: 2,
period: config.Duration{Duration: time.Hour}, Period: config.Duration{Duration: time.Hour},
}, },
}, },
}, },
@ -512,10 +512,10 @@ func TestRateLimitError(t *testing.T) {
allowed: false, allowed: false,
retryIn: 15 * time.Second, retryIn: 15 * time.Second,
transaction: Transaction{ transaction: Transaction{
limit: &limit{ limit: &Limit{
name: FailedAuthorizationsPerDomainPerAccount, Name: FailedAuthorizationsPerDomainPerAccount,
burst: 7, Burst: 7,
period: config.Duration{Duration: time.Hour}, Period: config.Duration{Duration: time.Hour},
}, },
bucketKey: "4:12345:example.com", bucketKey: "4:12345:example.com",
}, },
@ -529,10 +529,10 @@ func TestRateLimitError(t *testing.T) {
allowed: false, allowed: false,
retryIn: 20 * time.Second, retryIn: 20 * time.Second,
transaction: Transaction{ transaction: Transaction{
limit: &limit{ limit: &Limit{
name: CertificatesPerDomain, Name: CertificatesPerDomain,
burst: 3, Burst: 3,
period: config.Duration{Duration: time.Hour}, Period: config.Duration{Duration: time.Hour},
}, },
bucketKey: "5:example.org", bucketKey: "5:example.org",
}, },
@ -546,10 +546,10 @@ func TestRateLimitError(t *testing.T) {
allowed: false, allowed: false,
retryIn: 20 * time.Second, retryIn: 20 * time.Second,
transaction: Transaction{ transaction: Transaction{
limit: &limit{ limit: &Limit{
name: CertificatesPerDomainPerAccount, Name: CertificatesPerDomainPerAccount,
burst: 3, Burst: 3,
period: config.Duration{Duration: time.Hour}, Period: config.Duration{Duration: time.Hour},
}, },
bucketKey: "6:12345678:example.net", bucketKey: "6:12345678:example.net",
}, },
@ -563,8 +563,8 @@ func TestRateLimitError(t *testing.T) {
allowed: false, allowed: false,
retryIn: 30 * time.Second, retryIn: 30 * time.Second,
transaction: Transaction{ transaction: Transaction{
limit: &limit{ limit: &Limit{
name: 9999999, Name: 9999999,
}, },
}, },
}, },

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/letsencrypt/boulder/iana" "github.com/letsencrypt/boulder/iana"
"github.com/letsencrypt/boulder/identifier"
"github.com/letsencrypt/boulder/policy" "github.com/letsencrypt/boulder/policy"
) )
@ -17,8 +18,9 @@ import (
// IMPORTANT: If you add or remove a limit Name, you MUST update: // IMPORTANT: If you add or remove a limit Name, you MUST update:
// - the string representation of the Name in nameToString, // - the string representation of the Name in nameToString,
// - the validators for that name in validateIdForName(), // - the validators for that name in validateIdForName(),
// - the transaction constructors for that name in bucket.go, and // - the transaction constructors for that name in bucket.go
// - the Subscriber facing error message in ErrForDecision(). // - the Subscriber facing error message in ErrForDecision(), and
// - the case in BuildBucketKey() for that name.
type Name int type Name int
const ( const (
@ -206,7 +208,7 @@ func validateRegIdIdentValue(id string) error {
// validateDomainOrCIDR validates that the provided string is either a domain // validateDomainOrCIDR validates that the provided string is either a domain
// name or an IP address. IPv6 addresses must be the lowest address in their // name or an IP address. IPv6 addresses must be the lowest address in their
// /64, i.e. their last 64 bits must be zero. // /64, i.e. their last 64 bits must be zero.
func validateDomainOrCIDR(id string) error { func validateDomainOrCIDR(limit Name, id string) error {
domainErr := policy.ValidDomain(id) domainErr := policy.ValidDomain(id)
if domainErr == nil { if domainErr == nil {
// This is a valid domain. // This is a valid domain.
@ -222,14 +224,13 @@ func validateDomainOrCIDR(id string) error {
return fmt.Errorf("invalid IP address %q, must be in canonical form (%q)", id, ip.String()) return fmt.Errorf("invalid IP address %q, must be in canonical form (%q)", id, ip.String())
} }
prefix, prefixErr := coveringPrefix(ip) prefix, prefixErr := coveringIPPrefix(limit, ip)
if prefixErr != nil { if prefixErr != nil {
return fmt.Errorf("invalid IP address %q, couldn't determine prefix: %w", id, prefixErr) return fmt.Errorf("invalid IP address %q, couldn't determine prefix: %w", id, prefixErr)
} }
if prefix.Addr() != ip { if prefix.Addr() != ip {
return fmt.Errorf("invalid IP address %q, must be the lowest address in its prefix (%q)", id, prefix.Addr().String()) return fmt.Errorf("invalid IP address %q, must be the lowest address in its prefix (%q)", id, prefix.Addr().String())
} }
return iana.IsReservedPrefix(prefix) return iana.IsReservedPrefix(prefix)
} }
@ -237,7 +238,7 @@ func validateDomainOrCIDR(id string) error {
// 'regId:domainOrCIDR', where domainOrCIDR is either a domain name or an IP // 'regId:domainOrCIDR', where domainOrCIDR is either a domain name or an IP
// address. IPv6 addresses must be the lowest address in their /64, i.e. their // address. IPv6 addresses must be the lowest address in their /64, i.e. their
// last 64 bits must be zero. // last 64 bits must be zero.
func validateRegIdDomainOrCIDR(id string) error { func validateRegIdDomainOrCIDR(limit Name, id string) error {
regIdDomainOrCIDR := strings.Split(id, ":") regIdDomainOrCIDR := strings.Split(id, ":")
if len(regIdDomainOrCIDR) != 2 { if len(regIdDomainOrCIDR) != 2 {
return fmt.Errorf( return fmt.Errorf(
@ -248,7 +249,7 @@ func validateRegIdDomainOrCIDR(id string) error {
return fmt.Errorf( return fmt.Errorf(
"invalid regId, %q must be formatted 'regId:domainOrCIDR'", id) "invalid regId, %q must be formatted 'regId:domainOrCIDR'", id)
} }
err = validateDomainOrCIDR(regIdDomainOrCIDR[1]) err = validateDomainOrCIDR(limit, regIdDomainOrCIDR[1])
if err != nil { if err != nil {
return fmt.Errorf("invalid domainOrCIDR, %q must be formatted 'regId:domainOrCIDR': %w", id, err) return fmt.Errorf("invalid domainOrCIDR, %q must be formatted 'regId:domainOrCIDR': %w", id, err)
} }
@ -301,7 +302,7 @@ func validateIdForName(name Name, id string) error {
case CertificatesPerDomainPerAccount: case CertificatesPerDomainPerAccount:
if strings.Contains(id, ":") { if strings.Contains(id, ":") {
// 'enum:regId:domainOrCIDR' for transaction // 'enum:regId:domainOrCIDR' for transaction
return validateRegIdDomainOrCIDR(id) return validateRegIdDomainOrCIDR(name, id)
} else { } else {
// 'enum:regId' for overrides // 'enum:regId' for overrides
return validateRegId(id) return validateRegId(id)
@ -309,7 +310,7 @@ func validateIdForName(name Name, id string) error {
case CertificatesPerDomain: case CertificatesPerDomain:
// 'enum:domainOrCIDR' // 'enum:domainOrCIDR'
return validateDomainOrCIDR(id) return validateDomainOrCIDR(name, id)
case CertificatesPerFQDNSet: case CertificatesPerFQDNSet:
// 'enum:fqdnSet' // 'enum:fqdnSet'
@ -333,8 +334,8 @@ func validateIdForName(name Name, id string) error {
} }
} }
// stringToName is a map of string names to Name values. // StringToName is a map of string names to Name values.
var stringToName = func() map[string]Name { var StringToName = func() map[string]Name {
m := make(map[string]Name, len(nameToString)) m := make(map[string]Name, len(nameToString))
for k, v := range nameToString { for k, v := range nameToString {
m[v] = k m[v] = k
@ -342,11 +343,94 @@ var stringToName = func() map[string]Name {
return m return m
}() }()
// limitNames is a slice of all rate limit names. // LimitNames is a slice of all rate limit names.
var limitNames = func() []string { var LimitNames = func() []string {
names := make([]string, 0, len(nameToString)) names := make([]string, 0, len(nameToString))
for _, v := range nameToString { for _, v := range nameToString {
names = append(names, v) names = append(names, v)
} }
return names return names
}() }()
// BuildBucketKey builds a bucketKey for the given rate limit name from the
// provided components. It returns an error if the name is not valid or if the
// components are not valid for the given name.
func BuildBucketKey(name Name, regId int64, singleIdent identifier.ACMEIdentifier, setOfIdents identifier.ACMEIdentifiers, subscriberIP netip.Addr) (string, error) {
makeMissingErr := func(field string) error {
return fmt.Errorf("%s is required for limit %s (enum: %s)", field, name, name.EnumString())
}
switch name {
case NewRegistrationsPerIPAddress:
if !subscriberIP.IsValid() {
return "", makeMissingErr("subscriberIP")
}
return newIPAddressBucketKey(name, subscriberIP), nil
case NewRegistrationsPerIPv6Range:
if !subscriberIP.IsValid() {
return "", makeMissingErr("subscriberIP")
}
prefix, err := coveringIPPrefix(name, subscriberIP)
if err != nil {
return "", err
}
return newIPv6RangeCIDRBucketKey(name, prefix), nil
case NewOrdersPerAccount:
if regId == 0 {
return "", makeMissingErr("regId")
}
return newRegIdBucketKey(name, regId), nil
case CertificatesPerDomain:
if singleIdent.Value == "" {
return "", makeMissingErr("singleIdent")
}
coveringIdent, err := coveringIdentifier(name, singleIdent)
if err != nil {
return "", err
}
return newDomainOrCIDRBucketKey(name, coveringIdent), nil
case CertificatesPerDomainPerAccount:
if singleIdent.Value != "" {
if regId == 0 {
return "", makeMissingErr("regId")
}
// Default: use 'enum:regId:identValue' bucket key format.
coveringIdent, err := coveringIdentifier(name, singleIdent)
if err != nil {
return "", err
}
return NewRegIdIdentValueBucketKey(name, regId, coveringIdent), nil
}
if regId == 0 {
return "", makeMissingErr("regId")
}
// Override: use 'enum:regId' bucket key format.
return newRegIdBucketKey(name, regId), nil
case CertificatesPerFQDNSet:
if len(setOfIdents) == 0 {
return "", makeMissingErr("setOfIdents")
}
return newFQDNSetBucketKey(name, setOfIdents), nil
case FailedAuthorizationsPerDomainPerAccount, FailedAuthorizationsForPausingPerDomainPerAccount:
if singleIdent.Value != "" {
if regId == 0 {
return "", makeMissingErr("regId")
}
// Default: use 'enum:regId:identValue' bucket key format.
return NewRegIdIdentValueBucketKey(name, regId, singleIdent.Value), nil
}
if regId == 0 {
return "", makeMissingErr("regId")
}
// Override: use 'enum:regId' bucket key format.
return newRegIdBucketKey(name, regId), nil
}
return "", fmt.Errorf("unknown limit enum %s", name.EnumString())
}

View File

@ -2,8 +2,11 @@ package ratelimits
import ( import (
"fmt" "fmt"
"net/netip"
"strings"
"testing" "testing"
"github.com/letsencrypt/boulder/identifier"
"github.com/letsencrypt/boulder/test" "github.com/letsencrypt/boulder/test"
) )
@ -293,3 +296,202 @@ func TestValidateIdForName(t *testing.T) {
}) })
} }
} }
func TestBuildBucketKey(t *testing.T) {
t.Parallel()
tests := []struct {
name Name
desc string
regId int64
singleIdent identifier.ACMEIdentifier
setOfIdents identifier.ACMEIdentifiers
subscriberIP netip.Addr
expectErrContains string
outputTest func(t *testing.T, key string)
}{
// NewRegistrationsPerIPAddress
{
name: NewRegistrationsPerIPAddress,
desc: "valid subscriber IPv4 address",
subscriberIP: netip.MustParseAddr("1.2.3.4"),
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:1.2.3.4", NewRegistrationsPerIPAddress), key)
},
},
{
name: NewRegistrationsPerIPAddress,
desc: "valid subscriber IPv6 address",
subscriberIP: netip.MustParseAddr("2001:db8::1"),
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:2001:db8::1", NewRegistrationsPerIPAddress), key)
},
},
// NewRegistrationsPerIPv6Range
{
name: NewRegistrationsPerIPv6Range,
desc: "valid subscriber IPv6 address",
subscriberIP: netip.MustParseAddr("2001:db8:abcd:12::1"),
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:2001:db8:abcd::/48", NewRegistrationsPerIPv6Range), key)
},
},
{
name: NewRegistrationsPerIPv6Range,
desc: "subscriber IPv4 given for subscriber IPv6 range limit",
subscriberIP: netip.MustParseAddr("1.2.3.4"),
expectErrContains: "requires an IPv6 address",
},
// NewOrdersPerAccount
{
name: NewOrdersPerAccount,
desc: "valid registration ID",
regId: 1337,
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:1337", NewOrdersPerAccount), key)
},
},
{
name: NewOrdersPerAccount,
desc: "registration ID missing",
expectErrContains: "regId is required",
},
// CertificatesPerDomain
{
name: CertificatesPerDomain,
desc: "DNS identifier to eTLD+1",
singleIdent: identifier.NewDNS("www.example.com"),
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:example.com", CertificatesPerDomain), key)
},
},
{
name: CertificatesPerDomain,
desc: "valid IPv4 address used as identifier",
singleIdent: identifier.NewIP(netip.MustParseAddr("5.6.7.8")),
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:5.6.7.8/32", CertificatesPerDomain), key)
},
},
{
name: CertificatesPerDomain,
desc: "valid IPv6 address used as identifier",
singleIdent: identifier.NewIP(netip.MustParseAddr("2001:db8::1")),
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:2001:db8::/64", CertificatesPerDomain), key)
},
},
{
name: CertificatesPerDomain,
desc: "identifier missing",
expectErrContains: "singleIdent is required",
},
// CertificatesPerFQDNSet
{
name: CertificatesPerFQDNSet,
desc: "multiple valid DNS identifiers",
setOfIdents: identifier.NewDNSSlice([]string{"example.com", "example.org"}),
outputTest: func(t *testing.T, key string) {
if !strings.HasPrefix(key, fmt.Sprintf("%d:", CertificatesPerFQDNSet)) {
t.Errorf("expected key to start with %d: got %s", CertificatesPerFQDNSet, key)
}
},
},
{
name: CertificatesPerFQDNSet,
desc: "multiple valid DNS and IP identifiers",
setOfIdents: identifier.ACMEIdentifiers{identifier.NewDNS("example.net"), identifier.NewIP(netip.MustParseAddr("5.6.7.8")), identifier.NewIP(netip.MustParseAddr("2001:db8::1"))},
outputTest: func(t *testing.T, key string) {
if !strings.HasPrefix(key, fmt.Sprintf("%d:", CertificatesPerFQDNSet)) {
t.Errorf("expected key to start with %d: got %s", CertificatesPerFQDNSet, key)
}
},
},
{
name: CertificatesPerFQDNSet,
desc: "identifiers missing",
expectErrContains: "setOfIdents is required",
},
// CertificatesPerDomainPerAccount
{
name: CertificatesPerDomainPerAccount,
desc: "only registration ID",
regId: 1337,
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:1337", CertificatesPerDomainPerAccount), key)
},
},
{
name: CertificatesPerDomainPerAccount,
desc: "registration ID and single DNS identifier provided",
regId: 1337,
singleIdent: identifier.NewDNS("example.com"),
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:1337:example.com", CertificatesPerDomainPerAccount), key)
},
},
{
name: CertificatesPerDomainPerAccount,
desc: "single DNS identifier provided without registration ID",
singleIdent: identifier.NewDNS("example.com"),
expectErrContains: "regId is required",
},
// FailedAuthorizationsPerDomainPerAccount
{
name: FailedAuthorizationsPerDomainPerAccount,
desc: "registration ID and single DNS identifier",
regId: 1337,
singleIdent: identifier.NewDNS("example.com"),
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:1337:example.com", FailedAuthorizationsPerDomainPerAccount), key)
},
},
{
name: FailedAuthorizationsPerDomainPerAccount,
desc: "only registration ID",
regId: 1337,
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:1337", FailedAuthorizationsPerDomainPerAccount), key)
},
},
// FailedAuthorizationsForPausingPerDomainPerAccount
{
name: FailedAuthorizationsForPausingPerDomainPerAccount,
desc: "registration ID and single DNS identifier",
regId: 1337,
singleIdent: identifier.NewDNS("example.com"),
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:1337:example.com", FailedAuthorizationsForPausingPerDomainPerAccount), key)
},
},
{
name: FailedAuthorizationsForPausingPerDomainPerAccount,
desc: "only registration ID",
regId: 1337,
outputTest: func(t *testing.T, key string) {
test.AssertEquals(t, fmt.Sprintf("%d:1337", FailedAuthorizationsForPausingPerDomainPerAccount), key)
},
},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("%s/%s", tc.name, tc.desc), func(t *testing.T) {
t.Parallel()
key, err := BuildBucketKey(tc.name, tc.regId, tc.singleIdent, tc.setOfIdents, tc.subscriberIP)
if tc.expectErrContains != "" {
test.AssertError(t, err, "expected error")
test.AssertContains(t, err.Error(), tc.expectErrContains)
return
}
test.AssertNotError(t, err, "unexpected error")
tc.outputTest(t, key)
})
}
}

View File

@ -3,5 +3,5 @@
count: 40 count: 40
period: 1s period: 1s
ids: ids:
- id: 10.0.0.2 - id: 55.66.77.88
comment: Foo comment: Foo

View File

@ -16,32 +16,25 @@ var ErrInvalidCost = fmt.Errorf("invalid cost, must be >= 0")
// ErrInvalidCostOverLimit indicates that the cost specified was > limit.Burst. // ErrInvalidCostOverLimit indicates that the cost specified was > limit.Burst.
var ErrInvalidCostOverLimit = fmt.Errorf("invalid cost, must be <= limit.Burst") var ErrInvalidCostOverLimit = fmt.Errorf("invalid cost, must be <= limit.Burst")
// newIPAddressBucketKey validates and returns a bucketKey for limits that use // newIPAddressBucketKey returns a bucketKey for limits that use
// the 'enum:ipAddress' bucket key format. // the 'enum:ipAddress' bucket key format.
func newIPAddressBucketKey(name Name, ip netip.Addr) string { //nolint:unparam // Only one named rate limit uses this helper func newIPAddressBucketKey(name Name, ip netip.Addr) string {
return joinWithColon(name.EnumString(), ip.String()) return joinWithColon(name.EnumString(), ip.String())
} }
// newIPv6RangeCIDRBucketKey validates and returns a bucketKey for limits that // newIPv6RangeCIDRBucketKey returns a bucketKey for limits that
// use the 'enum:ipv6RangeCIDR' bucket key format. // use the 'enum:ipv6RangeCIDR' bucket key format.
func newIPv6RangeCIDRBucketKey(name Name, ip netip.Addr) (string, error) { func newIPv6RangeCIDRBucketKey(name Name, prefix netip.Prefix) string {
if ip.Is4() { return joinWithColon(name.EnumString(), prefix.String())
return "", fmt.Errorf("invalid IPv6 address, %q must be an IPv6 address", ip.String())
}
prefix, err := ip.Prefix(48)
if err != nil {
return "", fmt.Errorf("invalid IPv6 address, can't calculate prefix of %q: %s", ip.String(), err)
}
return joinWithColon(name.EnumString(), prefix.String()), nil
} }
// newRegIdBucketKey validates and returns a bucketKey for limits that use the // newRegIdBucketKey returns a bucketKey for limits that use the
// 'enum:regId' bucket key format. // 'enum:regId' bucket key format.
func newRegIdBucketKey(name Name, regId int64) string { func newRegIdBucketKey(name Name, regId int64) string {
return joinWithColon(name.EnumString(), strconv.FormatInt(regId, 10)) return joinWithColon(name.EnumString(), strconv.FormatInt(regId, 10))
} }
// newDomainOrCIDRBucketKey validates and returns a bucketKey for limits that use // newDomainOrCIDRBucketKey returns a bucketKey for limits that use
// the 'enum:domainOrCIDR' bucket key formats. // the 'enum:domainOrCIDR' bucket key formats.
func newDomainOrCIDRBucketKey(name Name, domainOrCIDR string) string { func newDomainOrCIDRBucketKey(name Name, domainOrCIDR string) string {
return joinWithColon(name.EnumString(), domainOrCIDR) return joinWithColon(name.EnumString(), domainOrCIDR)
@ -56,7 +49,7 @@ func NewRegIdIdentValueBucketKey(name Name, regId int64, orderIdent string) stri
// newFQDNSetBucketKey validates and returns a bucketKey for limits that use the // newFQDNSetBucketKey validates and returns a bucketKey for limits that use the
// 'enum:fqdnSet' bucket key format. // 'enum:fqdnSet' bucket key format.
func newFQDNSetBucketKey(name Name, orderIdents identifier.ACMEIdentifiers) string { //nolint: unparam // Only one named rate limit uses this helper func newFQDNSetBucketKey(name Name, orderIdents identifier.ACMEIdentifiers) string {
return joinWithColon(name.EnumString(), fmt.Sprintf("%x", core.HashIdentifiers(orderIdents))) return joinWithColon(name.EnumString(), fmt.Sprintf("%x", core.HashIdentifiers(orderIdents)))
} }
@ -80,7 +73,7 @@ func newFQDNSetBucketKey(name Name, orderIdents identifier.ACMEIdentifiers) stri
// it would fail validateTransaction (for instance because cost and burst are zero). // it would fail validateTransaction (for instance because cost and burst are zero).
type Transaction struct { type Transaction struct {
bucketKey string bucketKey string
limit *limit limit *Limit
cost int64 cost int64
check bool check bool
spend bool spend bool
@ -102,7 +95,7 @@ func validateTransaction(txn Transaction) (Transaction, error) {
if txn.cost < 0 { if txn.cost < 0 {
return Transaction{}, ErrInvalidCost return Transaction{}, ErrInvalidCost
} }
if txn.limit.burst == 0 { if txn.limit.Burst == 0 {
// This should never happen. If the limit was loaded from a file, // This should never happen. If the limit was loaded from a file,
// Burst was validated then. If this is a zero-valued Transaction // Burst was validated then. If this is a zero-valued Transaction
// (that is, an allow-only transaction), then validateTransaction // (that is, an allow-only transaction), then validateTransaction
@ -110,13 +103,13 @@ func validateTransaction(txn Transaction) (Transaction, error) {
// valid. // valid.
return Transaction{}, fmt.Errorf("invalid limit, burst must be > 0") return Transaction{}, fmt.Errorf("invalid limit, burst must be > 0")
} }
if txn.cost > txn.limit.burst { if txn.cost > txn.limit.Burst {
return Transaction{}, ErrInvalidCostOverLimit return Transaction{}, ErrInvalidCostOverLimit
} }
return txn, nil return txn, nil
} }
func newTransaction(limit *limit, bucketKey string, cost int64) (Transaction, error) { func newTransaction(limit *Limit, bucketKey string, cost int64) (Transaction, error) {
return validateTransaction(Transaction{ return validateTransaction(Transaction{
bucketKey: bucketKey, bucketKey: bucketKey,
limit: limit, limit: limit,
@ -126,7 +119,7 @@ func newTransaction(limit *limit, bucketKey string, cost int64) (Transaction, er
}) })
} }
func newCheckOnlyTransaction(limit *limit, bucketKey string, cost int64) (Transaction, error) { func newCheckOnlyTransaction(limit *Limit, bucketKey string, cost int64) (Transaction, error) {
return validateTransaction(Transaction{ return validateTransaction(Transaction{
bucketKey: bucketKey, bucketKey: bucketKey,
limit: limit, limit: limit,
@ -135,7 +128,7 @@ func newCheckOnlyTransaction(limit *limit, bucketKey string, cost int64) (Transa
}) })
} }
func newSpendOnlyTransaction(limit *limit, bucketKey string, cost int64) (Transaction, error) { func newSpendOnlyTransaction(limit *Limit, bucketKey string, cost int64) (Transaction, error) {
return validateTransaction(Transaction{ return validateTransaction(Transaction{
bucketKey: bucketKey, bucketKey: bucketKey,
limit: limit, limit: limit,
@ -197,10 +190,12 @@ func (builder *TransactionBuilder) registrationsPerIPAddressTransaction(ip netip
// NewRegistrationsPerIPv6Range limit for the /48 IPv6 range which contains the // NewRegistrationsPerIPv6Range limit for the /48 IPv6 range which contains the
// provided IPv6 address. // provided IPv6 address.
func (builder *TransactionBuilder) registrationsPerIPv6RangeTransaction(ip netip.Addr) (Transaction, error) { func (builder *TransactionBuilder) registrationsPerIPv6RangeTransaction(ip netip.Addr) (Transaction, error) {
bucketKey, err := newIPv6RangeCIDRBucketKey(NewRegistrationsPerIPv6Range, ip) prefix, err := coveringIPPrefix(NewRegistrationsPerIPv6Range, ip)
if err != nil { if err != nil {
return Transaction{}, err return Transaction{}, fmt.Errorf("computing covering prefix for %q: %w", ip, err)
} }
bucketKey := newIPv6RangeCIDRBucketKey(NewRegistrationsPerIPv6Range, prefix)
limit, err := builder.getLimit(NewRegistrationsPerIPv6Range, bucketKey) limit, err := builder.getLimit(NewRegistrationsPerIPv6Range, bucketKey)
if err != nil { if err != nil {
if errors.Is(err, errLimitDisabled) { if errors.Is(err, errLimitDisabled) {

View File

@ -223,7 +223,7 @@ func TestNewTransactionBuilder(t *testing.T) {
newRegDefault, ok := tb.limitRegistry.defaults[NewRegistrationsPerIPAddress.EnumString()] newRegDefault, ok := tb.limitRegistry.defaults[NewRegistrationsPerIPAddress.EnumString()]
test.Assert(t, ok, "NewRegistrationsPerIPAddress was not populated in registry") test.Assert(t, ok, "NewRegistrationsPerIPAddress was not populated in registry")
test.AssertEquals(t, newRegDefault.burst, expectedBurst) test.AssertEquals(t, newRegDefault.Burst, expectedBurst)
test.AssertEquals(t, newRegDefault.count, expectedCount) test.AssertEquals(t, newRegDefault.Count, expectedCount)
test.AssertEquals(t, newRegDefault.period, expectedPeriod) test.AssertEquals(t, newRegDefault.Period, expectedPeriod)
} }

View File

@ -16,57 +16,106 @@ func joinWithColon(args ...string) string {
return strings.Join(args, ":") return strings.Join(args, ":")
} }
// coveringIdentifiers transforms a slice of ACMEIdentifiers into strings of // coveringIdentifiers returns the set of "covering" identifiers used to enforce
// their "covering" identifiers, for the CertificatesPerDomain limit. It also // the CertificatesPerDomain rate limit. For DNS names, this is the eTLD+1 as
// de-duplicates the output. For DNS identifiers, this is eTLD+1's; exact public // determined by the Public Suffix List; exact public suffix matches are
// suffix matches are included. For IP address identifiers, this is the address // preserved. For IP addresses, the covering prefix is /32 for IPv4 and /64 for
// (/32) for IPv4, or the /64 prefix for IPv6, in CIDR notation. // IPv6. This groups requests by registered domain or address block to match the
// scope of the limit. The result is deduplicated and lowercased. If the
// identifier type is unsupported, an error is returned.
func coveringIdentifiers(idents identifier.ACMEIdentifiers) ([]string, error) { func coveringIdentifiers(idents identifier.ACMEIdentifiers) ([]string, error) {
var covers []string var covers []string
for _, ident := range idents { for _, ident := range idents {
switch ident.Type { cover, err := coveringIdentifier(CertificatesPerDomain, ident)
case identifier.TypeDNS: if err != nil {
domain, err := publicsuffix.Domain(ident.Value) return nil, err
if err != nil {
if err.Error() == fmt.Sprintf("%s is a suffix", ident.Value) {
// If the public suffix is the domain itself, that's fine.
// Include the original name in the result.
covers = append(covers, ident.Value)
continue
} else {
return nil, err
}
}
covers = append(covers, domain)
case identifier.TypeIP:
ip, err := netip.ParseAddr(ident.Value)
if err != nil {
return nil, err
}
prefix, err := coveringPrefix(ip)
if err != nil {
return nil, err
}
covers = append(covers, prefix.String())
} }
covers = append(covers, cover)
} }
return core.UniqueLowerNames(covers), nil return core.UniqueLowerNames(covers), nil
} }
// coveringPrefix transforms a netip.Addr into its "covering" prefix, for the // coveringIdentifier returns the "covering" identifier used to enforce the
// CertificatesPerDomain limit. For IPv4, this is the IP address (/32). For // CertificatesPerDomain, CertificatesPerDomainPerAccount, and
// IPv6, this is the /64 that contains the address. // NewRegistrationsPerIPv6Range rate limits. For DNS names, this is the eTLD+1
func coveringPrefix(addr netip.Addr) (netip.Prefix, error) { // as determined by the Public Suffix List; exact public suffix matches are
var bits int // preserved. For IP addresses, the covering prefix depends on the limit:
if addr.Is4() { //
bits = 32 // - CertificatesPerDomain and CertificatesPerDomainPerAccount:
} else { // - /32 for IPv4
bits = 64 // - /64 for IPv6
//
// - NewRegistrationsPerIPv6Range:
// - /48 for IPv6 only
//
// This groups requests by registered domain or address block to match the scope
// of each limit. The result is deduplicated and lowercased. If the identifier
// type or limit is unsupported, an error is returned.
func coveringIdentifier(limit Name, ident identifier.ACMEIdentifier) (string, error) {
switch ident.Type {
case identifier.TypeDNS:
domain, err := publicsuffix.Domain(ident.Value)
if err != nil {
if err.Error() == fmt.Sprintf("%s is a suffix", ident.Value) {
// If the public suffix is the domain itself, that's fine.
// Include the original name in the result.
return ident.Value, nil
}
return "", err
}
return domain, nil
case identifier.TypeIP:
ip, err := netip.ParseAddr(ident.Value)
if err != nil {
return "", err
}
prefix, err := coveringIPPrefix(limit, ip)
if err != nil {
return "", err
}
return prefix.String(), nil
} }
prefix, err := addr.Prefix(bits) return "", fmt.Errorf("unsupported identifier type: %s", ident.Type)
if err != nil { }
// This should be impossible because bits is hardcoded.
return netip.Prefix{}, err // coveringIPPrefix returns the "covering" IP prefix used to enforce the
} // CertificatesPerDomain, CertificatesPerDomainPerAccount, and
return prefix, nil // NewRegistrationsPerIPv6Range rate limits. The prefix length depends on the
// limit and IP version:
//
// - CertificatesPerDomain and CertificatesPerDomainPerAccount:
// - /32 for IPv4
// - /64 for IPv6
//
// - NewRegistrationsPerIPv6Range:
// - /48 for IPv6 only
//
// This groups requests by address block to match the scope of each limit. If
// the limit does not require a covering prefix, an error is returned.
func coveringIPPrefix(limit Name, addr netip.Addr) (netip.Prefix, error) {
switch limit {
case CertificatesPerDomain, CertificatesPerDomainPerAccount:
var bits int
if addr.Is4() {
bits = 32
} else {
bits = 64
}
prefix, err := addr.Prefix(bits)
if err != nil {
return netip.Prefix{}, fmt.Errorf("building covering prefix for %s: %w", addr, err)
}
return prefix, nil
case NewRegistrationsPerIPv6Range:
if !addr.Is6() {
return netip.Prefix{}, fmt.Errorf("limit %s requires an IPv6 address, got %s", limit, addr)
}
prefix, err := addr.Prefix(48)
if err != nil {
return netip.Prefix{}, fmt.Errorf("building covering prefix for %s: %w", addr, err)
}
return prefix, nil
}
return netip.Prefix{}, fmt.Errorf("limit %s does not require a covering prefix", limit)
} }