diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 4c8c72c135..d7c7e4a6d3 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -372,6 +372,12 @@ func newConfig() *codersdk.DeploymentConfig { Default: 10 * time.Minute, }, }, + APIRateLimit: &codersdk.DeploymentConfigField[int]{ + Name: "API Rate Limit", + Usage: "Maximum number of requests per minute allowed to the API per user, or per IP address for unauthenticated users. Negative values mean no rate limit. Some API endpoints are always rate limited regardless of this value to prevent denial-of-service attacks.", + Flag: "api-rate-limit", + Default: 512, + }, Experimental: &codersdk.DeploymentConfigField[bool]{ Name: "Experimental", Usage: "Enable experimental features. Experimental features are not ready for production.", diff --git a/cli/server.go b/cli/server.go index bc4ccad533..271db948fe 100644 --- a/cli/server.go +++ b/cli/server.go @@ -363,6 +363,7 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co AgentStatsRefreshInterval: cfg.AgentStatRefreshInterval.Value, DeploymentConfig: cfg, PrometheusRegistry: prometheus.NewRegistry(), + APIRateLimit: cfg.APIRateLimit.Value, } if tlsConfig != nil { options.TLSCertificates = tlsConfig.Certificates diff --git a/cli/server_test.go b/cli/server_test.go index 596730237a..615bebbf9a 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -633,6 +633,94 @@ func TestServer(t *testing.T) { cancelFunc() <-serverErr }) + + t.Run("RateLimit", func(t *testing.T) { + t.Parallel() + + t.Run("Default", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + root, cfg := clitest.New(t, + "server", + "--in-memory", + "--address", ":0", + "--access-url", "http://example.com", + ) + serverErr := make(chan error, 1) + go func() { + serverErr <- root.ExecuteContext(ctx) + }() + accessURL := waitAccessURL(t, cfg) + client := codersdk.New(accessURL) + + resp, err := client.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "512", resp.Header.Get("X-Ratelimit-Limit")) + cancelFunc() + <-serverErr + }) + + t.Run("Changed", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + val := "100" + root, cfg := clitest.New(t, + "server", + "--in-memory", + "--address", ":0", + "--access-url", "http://example.com", + "--api-rate-limit", val, + ) + serverErr := make(chan error, 1) + go func() { + serverErr <- root.ExecuteContext(ctx) + }() + accessURL := waitAccessURL(t, cfg) + client := codersdk.New(accessURL) + + resp, err := client.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, val, resp.Header.Get("X-Ratelimit-Limit")) + cancelFunc() + <-serverErr + }) + + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + root, cfg := clitest.New(t, + "server", + "--in-memory", + "--address", ":0", + "--access-url", "http://example.com", + "--api-rate-limit", "-1", + ) + serverErr := make(chan error, 1) + go func() { + serverErr <- root.ExecuteContext(ctx) + }() + accessURL := waitAccessURL(t, cfg) + client := codersdk.New(accessURL) + + resp, err := client.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "", resp.Header.Get("X-Ratelimit-Limit")) + cancelFunc() + <-serverErr + }) + }) } func generateTLSCertificate(t testing.TB, commonName ...string) (certPath, keyPath string) { diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 2033e4c63d..d70e2e6f0c 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -17,6 +17,14 @@ Flags: -a, --address string Bind address of the server. Consumes $CODER_ADDRESS (default "127.0.0.1:3000") + --api-rate-limit int Maximum number of requests per minute + allowed to the API per user, or per IP + address for unauthenticated users. + Negative values mean no rate limit. Some + API endpoints are always rate limited + regardless of this value to prevent + denial-of-service attacks. + Consumes $CODER_API_RATE_LIMIT (default 512) --cache-dir string The directory to cache temporary files. If unspecified and $CACHE_DIRECTORY is set, it will be used for compatibility diff --git a/coderd/database/generate.sh b/coderd/database/generate.sh index 742ee5867f..d193f6a4c7 100755 --- a/coderd/database/generate.sh +++ b/coderd/database/generate.sh @@ -22,8 +22,12 @@ SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") first=true for fi in queries/*.sql.go; do - # Find the last line from the imports section and add 1. + # Find the last line from the imports section and add 1. We have to + # disable pipefail temporarily to avoid ERRPIPE errors when piping into + # `head -n1`. + set +o pipefail cut=$(grep -n ')' "$fi" | head -n 1 | cut -d: -f1) + set -o pipefail cut=$((cut + 1)) # Copy the header from the first file only, ignoring the source comment. diff --git a/codersdk/deploymentconfig.go b/codersdk/deploymentconfig.go index bc51fd806a..301c2382df 100644 --- a/codersdk/deploymentconfig.go +++ b/codersdk/deploymentconfig.go @@ -39,6 +39,7 @@ type DeploymentConfig struct { SCIMAPIKey *DeploymentConfigField[string] `json:"scim_api_key" typescript:",notnull"` UserWorkspaceQuota *DeploymentConfigField[int] `json:"user_workspace_quota" typescript:",notnull"` Provisioner *ProvisionerConfig `json:"provisioner" typescript:",notnull"` + APIRateLimit *DeploymentConfigField[int] `json:"api_rate_limit" typescript:",notnull"` Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"` } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cb4e186e4d..68362eb1ec 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -303,6 +303,7 @@ export interface DeploymentConfig { readonly scim_api_key: DeploymentConfigField readonly user_workspace_quota: DeploymentConfigField readonly provisioner: ProvisionerConfig + readonly api_rate_limit: DeploymentConfigField readonly experimental: DeploymentConfigField }