如何在应用平台上使用Node.js构建速率限制器
作者选择了 COVID-19 Relief Fund 作为 Write for DOnations 计划的一部分来接受捐赠。
介绍
速率限制管理您的网络流量并限制某人在给定持续时间内重复操作的次数,例如使用 API。 没有针对速率限制滥用的安全层的服务很容易过载并妨碍您的应用程序对合法客户的正常运行。
在本教程中,您将构建一个 Node.js 服务器,该服务器将检查请求的 IP 地址,并通过比较每个用户的请求时间戳来计算这些请求的速率。 如果 IP 地址超过您为应用程序设置的限制,您将调用 Cloudflare 的 API 并将 IP 地址添加到列表中。 然后,您将配置一个 Cloudflare 防火墙规则,该规则将禁止使用列表中 IP 地址的所有请求。
在本教程结束时,您将构建一个部署在 DigitalOcean 的 App Platform 上的 Node.js 项目,该项目通过速率限制保护 Cloudflare 路由域。
先决条件
在开始本指南之前,您需要:
- 一个 Cloudflare 帐户。 Cloudflare 的免费计划足以满足本教程的需求。 如果您要创建新帐户,请选择免费计划。 这份关于 创建 Cloudflare 帐户和添加网站 的指南可以为您提供帮助。
- 添加到您的 Cloudflare 帐户的注册域。 如何使用 Cloudflare 缓解针对您的网站的 DDoS 攻击的指南可以帮助您进行设置。 这篇关于 DNS 术语、组件和概念介绍 的文章也可以提供帮助。
- 带有 Node.js 的基本 Express 服务器。 按照 如何开始使用 Node.js 和 Express 文章直到第 2 步。
- 在本地机器上安装了 GitHub 帐户 和 git 。 需要一个 GitHub 帐户并安装 git,因为您将把代码推送到 GitHub 以从 DigitalOcean 应用程序平台进行部署。
- 一个 DigitalOcean 帐户。
第 1 步 — 设置 Node.js 项目并部署到 DigitalOcean 的应用平台
在这一步中,您将扩展您的基本 Express 服务器,将您的代码推送到 GitHub 存储库,并将您的应用程序部署到 App Platform。
使用代码编辑器打开基本 Express 服务器的项目目录。 在项目的根目录下创建一个名为 .gitignore
的新文件。 将以下行添加到新创建的 .gitignore
文件中:
.gitignore
node_modules/ .env
.gitignore
文件中的第一行是 git 不要跟踪 node_modules
目录的指令。 这将使您的存储库大小保持较小。 需要时可以通过运行命令npm install
生成node_modules
。 第二行防止环境变量文件被跟踪。 您将在进一步的步骤中创建 .env
文件。
在代码编辑器中导航到 server.js
并修改以下代码行:
服务器.js
... app.listen(process.env.PORT || 3000, () => { console.log(`Example app is listening on port ${process.env.PORT || 3000}`); });
有条件地使用 PORT
作为环境变量的更改使应用程序能够动态地让服务器在分配的 PORT
上运行,或者使用 3000
作为备用服务器。
注意: console.log()
中的字符串包含在反引号(`)中,而不是引号中。 这使您能够使用 模板文字 ,它提供了在字符串中包含表达式的能力。
访问您的终端窗口并运行您的应用程序:
node server.js
您的浏览器窗口将显示 Successful response
。 在您的终端中,您将看到以下输出:
OutputExample app is listening on port 3000
随着您的 Express 服务器成功运行,您现在将部署到 App Platform。
首先,在项目根目录初始化git
,并将代码推送到你的GitHub账号。 在浏览器中导航到App Platform仪表板,然后单击创建应用程序按钮。 如有必要,选择 GitHub 选项并通过 GitHub 进行授权。 从要部署到 App Platform 的项目的下拉列表中选择项目的存储库。 查看配置,然后为应用程序命名。 就本教程而言,选择 Basic 计划,因为您将在应用程序的开发阶段工作。 准备就绪后,单击 启动应用程序。
接下来,导航到 Settings 选项卡并单击 Domains 部分。 将您通过 Cloudflare 路由的域添加到 域或子域名称 字段中。 选择项目符号 You manage your domain 以复制 CNAME 记录,您将使用该记录添加到您的域的 Cloudflare DNS 帐户。
将您的应用程序部署到 App Platform 后,在 Cloudflare 上的新选项卡中转到您域的仪表板,因为稍后您将返回 App Platform 的仪表板。 导航到 DNS 选项卡。 点击 Add Record 按钮并选择 CNAME 作为您的 Type,@ 作为根,然后粘贴到 [ X134X] 您从应用平台复制。 单击 Save 按钮,然后导航到应用平台仪表板中 Settings 选项卡下的 Domains 部分,然后单击 Add Domain[X177X ] 按钮。
单击 Deployments 选项卡以查看部署的详细信息。 部署完成后,您可以打开your_domain
在浏览器上查看。 您的浏览器窗口将显示:Successful response
。 导航到 App Platform Dashboard 上的 Runtime Logs 选项卡,您将获得以下输出:
OutputExample app is listening on port 8080
注:端口号8080
是App平台默认分配的端口。 您可以通过在部署前查看应用程序时更改配置来覆盖此设置。
现在将您的应用程序部署到 App Platform,让我们看看如何概述缓存以计算对速率限制器的请求。
第 2 步 — 缓存用户的 IP 地址并计算每秒请求数
在此步骤中,您将使用时间戳数组将用户的 IP 地址存储在 cache 中,以监控每个用户 IP 地址的每秒请求数。 缓存是应用程序经常使用的数据的临时存储。 缓存中的数据通常保存在 RAM(随机存取存储器)等快速访问硬件中。 缓存的基本目标是通过减少访问其下方较慢存储层的需要来提高数据检索性能。 您将使用三个 npm 包:node-cache
、is-ip
和 request-ip
来帮助完成此过程。
request-ip
包捕获用于请求服务器的用户 IP 地址。 node-cache
包创建一个内存缓存,您将使用它来跟踪用户的请求。 您将使用 is-ip
包来检查 IP 地址是否为 IPv6 地址。 通过 npm 在终端上安装 node-cache
、is-ip
和 request-ip
包。
npm i node-cache is-ip request-ip
在代码编辑器中打开 server.js
文件并在 const express = require('express');
下方添加以下代码行:
服务器.js
... const requestIP = require('request-ip'); const nodeCache = require('node-cache'); const isIp = require('is-ip'); ...
这里的第一行从您安装的 request-ip
包中获取 requestIP
模块。 该模块捕获用于请求服务器的用户 IP 地址。 第二行从 node-cache
包中获取 nodeCache
模块。 nodeCache
创建一个内存缓存,您将使用它来跟踪每秒用户的请求。 第三行采用 is-ip
包中的 isIp
模块。 这将检查 IP 地址是否为 IPv6,您将根据 Cloudflare 的规范对其进行格式化以使用 CIDR 表示法。
在 server.js
文件中定义一组常量 变量。 您将在整个应用程序中使用这些常量。
服务器.js
... const TIME_FRAME_IN_S = 10; const TIME_FRAME_IN_MS = TIME_FRAME_IN_S * 1000; const MS_TO_S = 1 / 1000; const RPS_LIMIT = 2; ...
TIME_FRAME_IN_S
是一个常量变量,它将确定您的应用程序平均用户时间戳的时间段。 增加周期会增加缓存大小,因此会消耗更多内存。 TIME_FRAME_IN_MS
常量变量还将确定您的应用程序平均用户时间戳的时间段,但以毫秒为单位。 MS_TO_S
是用于将时间(以毫秒为单位)转换为秒的转换因子。 RPS_LIMIT
变量是应用程序的阈值限制,它将触发速率限制器,并根据应用程序的要求更改值。 RPS_LIMIT
变量中的值 2
是一个中等值,将在开发阶段触发。
使用 Express,您可以编写和使用 中间件 函数,这些函数可以访问到达您服务器的所有 HTTP 请求。 要定义中间件函数,您将调用 app.use()
并将其传递给函数。 创建一个名为 ipMiddleware
的函数作为中间件。
服务器.js
... const ipMiddleware = async function (req, res, next) { let clientIP = requestIP.getClientIp(req); if (isIp.v6(clientIP)) { clientIP = clientIP.split(':').splice(0, 4).join(':') + '::/64'; } next(); }; app.use(ipMiddleware); ...
requestIP
提供的 getClientIp()
函数将中间件的请求对象 req
作为参数。 .v6()
函数来自 is-ip
模块,如果传递给它的参数是 IPv6 地址,则返回 true
。 Cloudflare 的列表需要 /64
CIDR 表示法中的 IPv6 地址。 您需要格式化 IPv6 地址以遵循以下格式:aaaa:bbbb:cccc:dddd::/64
。 .split(':') 方法从包含 IP 地址的字符串创建一个数组,该 IP 地址由字符 :
分割。 .splice(0,4) 方法返回数组的前四个元素。 .join(':')
方法从数组中返回一个与字符 :
组合的字符串。
next()
调用指示中间件转到下一个中间件函数(如果有的话)。 在您的示例中,它将请求发送到 GET 路由 /
。 在函数的末尾包含这一点很重要。 否则,请求将不会从中间件向前移动。
通过在常量下方添加以下变量来初始化 node-cache
的实例:
服务器.js
... const IPCache = new nodeCache({ stdTTL: TIME_FRAME_IN_S, deleteOnExpire: false, checkperiod: TIME_FRAME_IN_S }); ...
使用常量变量 IPCache
,您将使用自定义属性覆盖 nodeCache
原生的默认参数:
stdTTL
:从缓存中逐出缓存元素的键值对的时间间隔(以秒为单位)。TTL
代表 Time To Live,是缓存过期时间的度量。deleteOnExpire
:设置为false
,因为您将编写自定义回调函数来处理expired
事件。checkperiod
:触发自动检查过期元素的时间间隔(以秒为单位)。 默认值为600
,并且由于您的应用程序的元素过期设置为较小的值,过期检查也将更快发生。
有关 node-cache
默认参数的更多信息,您会发现 node-cache npm 包的文档页面 很有用。 下图将帮助您可视化缓存如何存储数据:
您现在将为新 IP 地址创建一个新的键值对,如果缓存中存在 IP 地址,则附加到现有的键值对。 该值是一个时间戳数组,对应于对您的应用程序发出的每个请求。 在您的 server.js
文件中,在 IPCache
常量变量下方创建 updateCache()
函数以将请求的时间戳添加到缓存:
服务器.js
... const updateCache = (ip) => { let IPArray = IPCache.get(ip) || []; IPArray.push(new Date()); IPCache.set(ip, IPArray, (IPCache.getTtl(ip) - Date.now()) * MS_TO_S || TIME_FRAME_IN_S); }; ...
函数的第一行获取给定 IP 地址的时间戳数组,如果为 null,则使用空数组进行初始化。 在以下行中,您将 new Date()
函数捕获的当前时间戳推送到数组中。 node-cache
提供的 .set()
函数接受三个参数:key
、value
和 TTL
。 此 TTL
将通过替换 IPCache
变量中的 stdTTL
的值来覆盖标准 TTL 设置。 如果缓存中已经存在 IP 地址,则使用现有的 TTL; 否则,您将设置 TTL 为 TIME_FRAME_IN_S
。
当前键值对的 TTL 是通过从到期时间戳中减去当前时间戳来计算的。 然后将差值转换为秒并作为第三个参数传递给 .set()
函数。 .getTtl()
函数将键和 IP 地址作为参数,并将键值对的 TTL 作为时间戳返回。 如果缓存中不存在 IP 地址,则返回 undefined
并使用 TIME_FRAME_IN_S
的备用值。
注意: 您需要从毫秒到秒的转换时间戳,因为 JavaScript 以毫秒为单位存储它们,而 node-cache
模块使用秒。
在 ipMiddleware
中间件中,在 if
代码块 if (isIp.v6(clientIP))
之后添加以下行,以计算调用您的应用程序的 IP 地址每秒的请求数:
服务器.js
... updateCache(clientIP); const IPArray = IPCache.get(clientIP); if (IPArray.length > 1) { const rps = IPArray.length / ((IPArray[IPArray.length - 1] - IPArray[0]) * MS_TO_S); if (rps > RPS_LIMIT) { console.log('You are hitting limit', clientIP); } } ...
第一行通过调用您声明的 updateCache()
函数将 IP 地址发出的请求的时间戳添加到缓存中。 第二行收集 IP 地址的时间戳数组。 如果时间戳数组中的元素个数大于一个(计算每秒请求数至少需要两个时间戳),并且每秒请求数大于您在常量中定义的阈值,您将 [ X247X] IP 地址。 rps
变量通过将请求数除以时间间隔差来计算每秒请求数,并将单位转换为秒。
由于您已将 IPCache
变量中的 deleteOnExpire
属性默认为 false
值,因此您现在需要手动处理 expired
事件。 node-cache
提供了一个回调函数,触发 expired
事件。 在 IPCache
常量变量下方添加以下代码行:
服务器.js
... IPCache.on('expired', (key, value) => { if (new Date() - value[value.length - 1] > TIME_FRAME_IN_MS) { IPCache.del(key); } }); ...
.on()
是一个回调函数,接受过期元素的 key
和 value
作为参数。 在您的缓存中,value
是一组请求的时间戳。 突出显示的行检查数组中的最后一个元素在过去是否比现在至少为 TIME_FRAME_IN_S
。 当您向时间戳数组添加元素时,如果 value
中的最后一个元素在过去比现在至少为 TIME_FRAME_IN_S
,则 .del()
函数采用 [X175X ] 作为参数并从缓存中删除过期元素。
对于数组的某些元素在过去比现在至少为 TIME_FRAME_IN_S
的情况,您需要通过从缓存中删除过期项目来处理它。 在if
代码块if (new Date() - value[value.length - 1] > TIME_FRAME_IN_MS)
之后的回调函数中添加如下代码。
服务器.js
... else { const updatedValue = value.filter(function (element) { return new Date() - element < TIME_FRAME_IN_MS; }); IPCache.set(key, updatedValue, TIME_FRAME_IN_S - (new Date() - updatedValue[0]) * MS_TO_S); } ...
JavaScript 原生的 filter() 数组方法提供了一个回调函数来过滤时间戳数组中的元素。 在您的情况下,突出显示的行检查过去比现在至少 TIME_FRAME_IN_S
的元素。 然后将过滤后的元素添加到 updatedValue
变量中。 这将使用 updatedValue
变量中的过滤元素和新的 TTL 更新您的缓存。 当缓存移除后面的元素时,匹配到updatedValue
变量中第一个元素的TTL会触发.on('expired')
回调函数。 TIME_FRAME_IN_S
与自 updatedValue
中第一个请求的时间戳以来过期的时间之差计算新的和更新的 TTL。
现在定义了中间件函数,访问终端窗口并运行应用程序:
node server.js
然后,在您的网络浏览器中访问 localhost:3000
。 您的浏览器窗口将显示:Successful response
。 反复刷新页面,点击【X39X】【X43X】。 您的终端窗口将显示:
OutputExample app is listening on port 3000 You are hitting limit ::1
注: localhost的IP地址显示为::1
。 当部署在 localhost 之外时,您的应用程序将捕获用户的公共 IP。
您的应用程序现在能够跟踪用户的请求并将时间戳存储在缓存中。 在下一步中,您将集成 Cloudflare 的 API 来设置防火墙。
第 3 步 — 设置 Cloudflare 防火墙
在此步骤中,您将设置 Cloudflare 的防火墙以在达到速率限制时阻止 IP 地址、创建环境变量并调用 Cloudflare API。
在浏览器中访问 Cloudflare 仪表板,登录并导航到您帐户的主页。 在 Configurations 选项卡下打开 Lists。 以 your_list
作为名称创建一个新列表。
注意: Lists 部分在您的 Cloudflare 帐户的仪表板页面上可用,而不是在您的 Cloudflare 域的仪表板页面上。
导航到 Home 选项卡并打开 your_domain
' 的仪表板。 打开防火墙选项卡,然后单击防火墙规则部分下的创建防火墙规则。 将 your_rule_name
给防火墙以识别它。 在 Field 中,从下拉列表中选择 IP Source Address
,Operator 选择 is in list
,Value 选择 your_list
。 在 Choose an action 的下拉列表中,选择 Block 并单击 Deploy。
在项目的根目录中创建一个 .env
文件,其中包含以下行以从您的应用程序调用 Cloudflare API:
.env
ACCOUNT_MAIL=your_cloudflare_login_mail API_KEY=your_api_key ACCOUNT_ID=your_account_id LIST_ID=your_list_id
要获取 API_KEY
的值,请导航到 Cloudflare 仪表板的 我的个人资料 部分上的 API 令牌 选项卡。 单击 Global API Key 部分中的 View 并输入您的 Cloudflare 密码以查看它。 访问帐户主页上 Configurations 选项卡下的 Lists 部分。 单击您创建的 your_list
列表旁边的 Edit。 从浏览器中your_list
的URL获取ACCOUNT_ID
和LIST_ID
。 URL 格式如下:https://dash.cloudflare.com/your_account_id/configurations/lists/your_list_id
警告: 确保 .env
的内容保密,不公开。 确保您在步骤 1 中创建的 .gitignore
文件中列出了 .env
文件。 <$>
通过 npm 在终端上安装 axios 和 dotenv
包。
npm i axios dotenv
在代码编辑器中打开 server.js
文件,然后在 nodeCache
常量变量下方添加以下代码行:
服务器.js
... const axios = require('axios'); require('dotenv').config(); ...
这里的第一行从您安装的 axios
包中获取 axios
模块。 您将使用此模块对 Cloudflare 的 API 进行网络调用。 第二行需要并配置 dotenv
模块以启用 process.env
全局变量,该全局变量将您放置在 .env
文件中的值定义为 server.js
。
将以下内容添加到 console.log('You are hitting limit', clientIP)
上方 ipMiddleware
内的 if (rps > RPS_LIMIT)
条件中以调用 Cloudflare API。
服务器.js
... const url = `https://api.cloudflare.com/client/v4/accounts/${process.env.ACCOUNT_ID}/rules/lists/${process.env.LIST_ID}/items`; const body = [{ ip: clientIP, comment: 'your_comment' }]; const headers = { 'X-Auth-Email': process.env.ACCOUNT_MAIL, 'X-Auth-Key': process.env.API_KEY, 'Content-Type': 'application/json', }; try { await axios.post(url, body, { headers }); } catch (error) { console.log(error); } ...
您现在通过 URL 调用 Cloudflare API 以将项目(在本例中为 IP 地址)添加到 your_list
。 Cloudflare API 将您的 ACCOUNT_MAIL
和 API_KEY
放在请求的标头中,键为 X-Auth-Email
和 X-Auth-Key
。 请求的主体采用一个对象数组,其中 ip
作为要添加到列表的 IP 地址,以及一个具有 your_comment
值的 comment
来标识条目。 您可以使用自己的自定义注释修改 comment
的值。 通过 axios.post()
发出的 POST 请求被包装在一个 try-catch 块中,以处理可能发生的错误(如果有)。 axios.post
函数接受 url
、body
和带有 headers
的对象来发出请求。
在使用 198.51.100.0/24
之类的测试 IP 地址测试 API 请求时,更改 ipMiddleware
函数中的 clientIP
变量,因为 Cloudflare 不接受其列表中的本地主机 IP 地址。
服务器.js
... let clientIP = '198.51.100.0/24'; ...
访问您的终端窗口并运行您的应用程序:
node server.js
然后,在您的网络浏览器中访问 localhost:3000
。 您的浏览器窗口将显示:Successful response
。 反复刷新页面,点击【X39X】【X43X】。 您的终端窗口将显示:
OutputExample app is listening on port 3000 You are hitting limit ::1
达到限制后,打开 Cloudflare 仪表板并导航到 your_list
's 页面。 您将看到添加到名为 your_list
的 Cloudflare 列表中的代码中输入的 IP 地址。 将您的更改推送到 GitHub 后,将显示防火墙页面。
<$>[警告] 警告: 确保更改您的值clientIP
可变为requestIP.getClientIp(req)
在将代码部署或推送到 GitHub 之前。
通过提交更改并将代码推送到 GitHub 来部署您的应用程序。 设置自动部署后,来自 GitHub 的代码将自动部署到 DigitalOcean 的应用平台。 由于您的 .env
文件未添加到 GitHub,因此您需要通过 App-Level Environment Variables 部分的 Settings 选项卡将其添加到 App Platform。 从项目的 .env
文件中添加键值对,以便您的应用程序可以在应用平台上访问其内容。 保存环境变量后,部署完成后在浏览器中打开your_domain
,反复刷新页面,点击RPS_LIMIT
。 一旦达到限制,浏览器将显示 Cloudflare 的防火墙页面。
导航到 App Platform Dashboard 上的 Runtime Logs 选项卡,您将看到以下输出:
Output... You are hitting limit your_public_ip
您可以从其他设备或通过 VPN 打开 your_domain
以查看防火墙仅禁止 your_list
中的 IP 地址。 您可以通过 Cloudflare 仪表板从 your_list
中删除 IP 地址。
注意: 有时,由于浏览器缓存响应,防火墙需要几秒钟才能触发。
您已设置 Cloudflare 的防火墙,以在用户通过调用 Cloudflare API 达到速率限制时阻止 IP 地址。
结论
在本文中,您构建了一个部署在 DigitalOcean 的应用平台上的 Node.js 项目,该平台连接到通过 Cloudflare 路由的域。 您通过在 Cloudflare 上配置防火墙规则来保护您的域免受速率限制滥用。 从这里,您可以修改防火墙规则以显示 JS Challenge 或 CAPTCHA,而不是禁止用户。 Cloudflare 文档 详细介绍了该过程。