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:
parent
c1ce0c83d0
commit
05e631593e
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue