Files
threshold/internal/cmd/commands/server/controller_ratelimit_reload_test.go
T
Timothy Messier 6e233c879f fix(ratelimit): Round up when setting Retry-After header
Previously this would truncate to the nearest integer, which could
result in clients retrying before the quota has reset, and getting rate
limited again. This also includes an update from go-rate that will round
up when reporting the reset time for the RateLimit header.

Note that in the case of a 503, the Retry-After header was already being
set correctly.

See: https://github.com/hashicorp/go-rate/pull/8
2023-12-04 21:35:20 +00:00

576 lines
16 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package server
import (
"fmt"
"net/http"
"os"
"sync"
"testing"
"time"
"github.com/hashicorp/boundary/internal/cmd/config"
"github.com/mitchellh/cli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
ratelimitConfig = `
disable_mlock = true
telemetry {
prometheus_retention_time = "24h"
disable_hostname = true
}
controller {
name = "test-controller"
description = "A default controller created for tests"
database {
url = "%s"
}
api_rate_limit {
resources = ["*"]
actions = ["*"]
per = "total"
limit = 2
period = "1m"
}
}
kms "aead" {
purpose = "root"
aead_type = "aes-gcm"
key = "%s"
key_id = "global_root"
}
kms "aead" {
purpose = "worker-auth"
aead_type = "aes-gcm"
key = "%s"
key_id = "global_worker-auth"
}
kms "aead" {
purpose = "recovery"
aead_type = "aes-gcm"
key = "%s"
key_id = "global_recovery"
}
listener "tcp" {
purpose = "api"
address = "127.0.0.1:9500"
tls_disable = true
}
listener "tcp" {
address = "127.0.0.1:9501"
purpose = "cluster"
}
`
ratelimitConfigReload = `
disable_mlock = true
telemetry {
prometheus_retention_time = "24h"
disable_hostname = true
}
controller {
name = "test-controller"
description = "A default controller created for tests"
database {
url = "%s"
}
api_rate_limit {
resources = ["*"]
actions = ["*"]
per = "total"
limit = 5
period = "1m"
}
}
kms "aead" {
purpose = "root"
aead_type = "aes-gcm"
key = "%s"
key_id = "global_root"
}
kms "aead" {
purpose = "worker-auth"
aead_type = "aes-gcm"
key = "%s"
key_id = "global_worker-auth"
}
kms "aead" {
purpose = "recovery"
aead_type = "aes-gcm"
key = "%s"
key_id = "global_recovery"
}
listener "tcp" {
purpose = "api"
address = "127.0.0.1:9500"
tls_disable = true
}
listener "tcp" {
address = "127.0.0.1:9501"
purpose = "cluster"
}
`
ratelimitConfigDisabledReload = `
disable_mlock = true
telemetry {
prometheus_retention_time = "24h"
disable_hostname = true
}
controller {
name = "test-controller"
description = "A default controller created for tests"
database {
url = "%s"
}
api_rate_limit_disable = true
}
kms "aead" {
purpose = "root"
aead_type = "aes-gcm"
key = "%s"
key_id = "global_root"
}
kms "aead" {
purpose = "worker-auth"
aead_type = "aes-gcm"
key = "%s"
key_id = "global_worker-auth"
}
kms "aead" {
purpose = "recovery"
aead_type = "aes-gcm"
key = "%s"
key_id = "global_recovery"
}
listener "tcp" {
purpose = "api"
address = "127.0.0.1:9500"
tls_disable = true
}
listener "tcp" {
address = "127.0.0.1:9501"
purpose = "cluster"
}
`
)
func TestRealodControllerRateLimits(t *testing.T) {
td := t.TempDir()
controllerKey := config.DevKeyGeneration()
closeDB, url, _, err := getInitDatabase(t, controllerKey)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB()) })
cmd := testServerCommand(t, testServerCommandOpts{})
workerAuthKey := config.DevKeyGeneration()
recoveryKey := config.DevKeyGeneration()
cfgHcl := fmt.Sprintf(ratelimitConfig, url, controllerKey, workerAuthKey, recoveryKey)
require.NoError(t, os.WriteFile(td+"/config.hcl", []byte(cfgHcl), 0o644))
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
args := []string{"-config", td + "/config.hcl"}
exitCode := cmd.Run(args)
if exitCode != 0 {
output := cmd.UI.(*cli.MockUi).ErrorWriter.String() + cmd.UI.(*cli.MockUi).OutputWriter.String()
t.Errorf("got a non-zero exit status: %s", output)
}
}()
// Wait until things are up and running (or timeout).
select {
case <-cmd.startedCh:
case <-time.After(15 * time.Second):
t.Fatal("timeout")
}
// Change config so it is ready for reloading
cfgHcl = fmt.Sprintf(ratelimitConfigReload, url, controllerKey, workerAuthKey, recoveryKey)
require.NoError(t, os.WriteFile(td+"/config.hcl", []byte(cfgHcl), 0o644))
c := http.Client{}
r, err := c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, `limit=2, remaining=1, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, `limit=2, remaining=0, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// out of quota, so we expect a 429
assert.Equal(t, http.StatusTooManyRequests, r.StatusCode)
assert.Equal(t, `limit=2, remaining=0, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
cmd.SighupCh <- struct{}{}
select {
case <-cmd.reloadedCh:
case <-time.After(15 * time.Second):
t.Fatal("timeout")
}
// Make another request, the limit should have reset and the new limit
// should get reported via the headers.
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, `limit=5, remaining=4, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `5;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
cmd.ShutdownCh <- struct{}{}
wg.Wait()
}
func TestRealodControllerRateLimitsSameConfig(t *testing.T) {
td := t.TempDir()
// Create and migrate database A and B.
controllerKey := config.DevKeyGeneration()
closeDB, url, _, err := getInitDatabase(t, controllerKey)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB()) })
cmd := testServerCommand(t, testServerCommandOpts{})
workerAuthKey := config.DevKeyGeneration()
recoveryKey := config.DevKeyGeneration()
cfgHcl := fmt.Sprintf(ratelimitConfig, url, controllerKey, workerAuthKey, recoveryKey)
require.NoError(t, os.WriteFile(td+"/config.hcl", []byte(cfgHcl), 0o644))
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
args := []string{"-config", td + "/config.hcl"}
exitCode := cmd.Run(args)
if exitCode != 0 {
output := cmd.UI.(*cli.MockUi).ErrorWriter.String() + cmd.UI.(*cli.MockUi).OutputWriter.String()
t.Errorf("got a non-zero exit status: %s", output)
}
}()
// Wait until things are up and running (or timeout).
select {
case <-cmd.startedCh:
case <-time.After(15 * time.Second):
t.Fatal("timeout")
}
c := http.Client{}
r, err := c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, `limit=2, remaining=1, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, `limit=2, remaining=0, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// out of quota, so we expect a 429
assert.Equal(t, http.StatusTooManyRequests, r.StatusCode)
assert.Equal(t, `limit=2, remaining=0, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
cmd.SighupCh <- struct{}{}
select {
case <-cmd.reloadedCh:
case <-time.After(15 * time.Second):
t.Fatal("timeout")
}
// Make another request, the limit should not have reset, and the current
// quota should still apply.
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// should still be rate limited, so 429
assert.Equal(t, http.StatusTooManyRequests, r.StatusCode)
assert.Equal(t, `limit=2, remaining=0, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
cmd.ShutdownCh <- struct{}{}
wg.Wait()
}
func TestRealodControllerRateLimitsDisable(t *testing.T) {
td := t.TempDir()
controllerKey := config.DevKeyGeneration()
closeDB, url, _, err := getInitDatabase(t, controllerKey)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB()) })
cmd := testServerCommand(t, testServerCommandOpts{})
workerAuthKey := config.DevKeyGeneration()
recoveryKey := config.DevKeyGeneration()
cfgHcl := fmt.Sprintf(ratelimitConfig, url, controllerKey, workerAuthKey, recoveryKey)
require.NoError(t, os.WriteFile(td+"/config.hcl", []byte(cfgHcl), 0o644))
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
args := []string{"-config", td + "/config.hcl"}
exitCode := cmd.Run(args)
if exitCode != 0 {
output := cmd.UI.(*cli.MockUi).ErrorWriter.String() + cmd.UI.(*cli.MockUi).OutputWriter.String()
t.Errorf("got a non-zero exit status: %s", output)
}
}()
// Wait until things are up and running (or timeout).
select {
case <-cmd.startedCh:
case <-time.After(15 * time.Second):
t.Fatal("timeout")
}
// Change config so it is ready for reloading
cfgHcl = fmt.Sprintf(ratelimitConfigDisabledReload, url, controllerKey, workerAuthKey, recoveryKey)
require.NoError(t, os.WriteFile(td+"/config.hcl", []byte(cfgHcl), 0o644))
c := http.Client{}
r, err := c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, `limit=2, remaining=1, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, `limit=2, remaining=0, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// out of quota, so we expect a 429
assert.Equal(t, http.StatusTooManyRequests, r.StatusCode)
assert.Equal(t, `limit=2, remaining=0, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
cmd.SighupCh <- struct{}{}
select {
case <-cmd.reloadedCh:
case <-time.After(15 * time.Second):
t.Fatal("timeout")
}
// Make another request, the limit should be disabled
// should get reported via the headers.
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, ``, r.Header.Get("Ratelimit"))
assert.Equal(t, ``, r.Header.Get("Ratelimit-Policy"))
cmd.ShutdownCh <- struct{}{}
wg.Wait()
}
func TestRealodControllerRateLimitsEnable(t *testing.T) {
td := t.TempDir()
controllerKey := config.DevKeyGeneration()
closeDB, url, _, err := getInitDatabase(t, controllerKey)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB()) })
cmd := testServerCommand(t, testServerCommandOpts{})
workerAuthKey := config.DevKeyGeneration()
recoveryKey := config.DevKeyGeneration()
// Start with rate limiting diasabled
cfgHcl := fmt.Sprintf(ratelimitConfigDisabledReload, url, controllerKey, workerAuthKey, recoveryKey)
require.NoError(t, os.WriteFile(td+"/config.hcl", []byte(cfgHcl), 0o644))
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
args := []string{"-config", td + "/config.hcl"}
exitCode := cmd.Run(args)
if exitCode != 0 {
output := cmd.UI.(*cli.MockUi).ErrorWriter.String() + cmd.UI.(*cli.MockUi).OutputWriter.String()
t.Errorf("got a non-zero exit status: %s", output)
}
}()
// Wait until things are up and running (or timeout).
select {
case <-cmd.startedCh:
case <-time.After(15 * time.Second):
t.Fatal("timeout")
}
// Change config so it is ready for reloading
cfgHcl = fmt.Sprintf(ratelimitConfig, url, controllerKey, workerAuthKey, recoveryKey)
require.NoError(t, os.WriteFile(td+"/config.hcl", []byte(cfgHcl), 0o644))
c := http.Client{}
r, err := c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, ``, r.Header.Get("Ratelimit"))
assert.Equal(t, ``, r.Header.Get("Ratelimit-Policy"))
cmd.SighupCh <- struct{}{}
select {
case <-cmd.reloadedCh:
case <-time.After(15 * time.Second):
t.Fatal("timeout")
}
// Make another request, the limit should be enabled
// should get reported via the headers.
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, `limit=2, remaining=1, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// unauthed request, so we expect a 400
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
assert.Equal(t, `limit=2, remaining=0, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
r, err = c.Do(func() *http.Request {
r, err := http.NewRequest(http.MethodGet, `http://127.0.0.1:9500/v1/targets`, nil)
require.NoError(t, err)
return r
}())
require.NoError(t, err)
// out of quota, so we expect a 429
assert.Equal(t, http.StatusTooManyRequests, r.StatusCode)
assert.Equal(t, `limit=2, remaining=0, reset=60`, r.Header.Get("Ratelimit"))
assert.Equal(t, `2;w=60;comment="total", 60;w=30;comment="ip-address", 60;w=30;comment="auth-token"`, r.Header.Get("Ratelimit-Policy"))
cmd.ShutdownCh <- struct{}{}
wg.Wait()
}