静态的防火墙规则集正在成为我们运维流程中的一个瓶颈。每次更新IP黑名单、调整某个API的速率限制,或是紧急封堵一个新发现的扫描特征,都意味着修改配置文件、推送到配置中心、然后逐台服务器执行nginx -s reload
。这个过程不仅繁琐,更重要的是,它不是实时的。从发现威胁到规则生效,中间的延迟窗口在某些场景下是不可接受的。我们需要的是一个动态的、能由运营或安全团队通过一个简单的Web界面实时推送规则的轻量级WAF(Web Application Firewall)。
我们的技术栈选型围绕着高性能和高动态性展开。网关层自然选择了OpenResty,它基于NGINX,并深度整合了LuaJIT,这为我们在请求处理的各个阶段注入自定义逻辑提供了无限可能。而实现动态配置的核心,在于如何让所有OpenResty worker进程高效、低延迟地获取到最新的规则。
最初的构想很简单:在access
阶段,让Lua脚本去查询一个外部数据源,比如Redis。
# /usr/local/openresty/nginx/conf/nginx.conf
# ... worker_processes, events ...
http {
# ... other http config ...
# Lua 连接 Redis 的超时设置
lua_package_path "/path/to/lua-resty-redis/lib/?.lua;;";
server {
listen 80;
server_name localhost;
location / {
access_by_lua_block {
-- 每次请求都直连 Redis,这是一个反模式的起点
local redis = require "resty.redis"
local red = redis:new()
red:set_timeout(1000) -- 1 sec
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect to redis: ", err)
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
-- 假设我们在 Redis 中用一个 SET 存储了黑名单 IP
local client_ip = ngx.var.remote_addr
local is_member, err = red:sismember("waf:blacklist:ip", client_ip)
if err then
ngx.log(ngx.ERR, "failed to check redis set: ", err)
-- 在无法确认时,默认放行,记录错误
return
end
if is_member == 1 then
-- 命中黑名单,直接拒绝
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Don't forget to put the connection back to the connection pool
red:set_keepalive(10000, 100)
}
# ... proxy_pass to upstream ...
proxy_pass http://my_backend;
}
}
}
这个方案虽然能工作,但在真实项目中是完全不可接受的。最大的问题在于性能:每一个进入NGINX的请求,都会触发一次到Redis的网络连接和查询。在高并发场景下,这会瞬间打垮Redis服务器,同时Lua阻塞的网络IO也会严重拖累NGINX的事件循环,导致请求处理延迟急剧上升。
引入本地缓存:lua_shared_dict
解决上述问题的标准做法是在NGINX内部引入一层本地缓存。OpenResty为此提供了lua_shared_dict
,这是一个在所有worker进程之间共享的内存区域,读写操作是原子性的,速度极快。
思路升级:规则主体存储在Redis中,但每个worker进程会将规则缓存到lua_shared_dict
中。请求进来时,优先查询本地共享内存,只有当本地缓存不存在或需要更新时,才去访问Redis。
# /usr/local/openresty/nginx/conf/nginx.conf
http {
# ...
# 定义一个 10MB 的共享内存区域,用于存储WAF规则
lua_shared_dict waf_rules_cache 10m;
server {
listen 80;
# ...
location / {
access_by_lua_block {
local waf_cache = ngx.shared.waf_rules_cache
local client_ip = ngx.var.remote_addr
-- 1. 优先查询本地缓存
-- 为了区分 "不存在" 和 "存在但值为false",我们存储 "1" 代表在黑名单中
local is_blacklisted = waf_cache:get("blacklist:ip:" .. client_ip)
if is_blacklisted == "1" then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
if is_blacklisted == nil then
-- 缓存未命中,需要查询 Redis
-- 注意:这里仍然存在性能问题,即缓存穿透
-- 所有对新 IP 的请求都会去查 Redis
-- 这被称为 "thundering herd" 问题
local redis = require "resty.redis"
local red = redis:new()
-- ... connect to redis ...
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect to redis: ", err)
return
end
local is_member, err = red:sismember("waf:blacklist:ip", client_ip)
red:set_keepalive(10000, 100)
if is_member == 1 then
-- 查询到是黑名单,写入本地缓存,并设置一个过期时间(例如5分钟)
waf_cache:set("blacklist:ip:" .. client_ip, "1", 300)
return ngx.exit(ngx.HTTP_FORBIDDEN)
else
-- 不是黑名单,也写入一个标记,防止后续请求继续穿透
-- "0" 代表已确认不在黑名单中,缓存1分钟
waf_cache:set("blacklist:ip:" .. client_ip, "0", 60)
end
end
}
proxy_pass http://my_backend;
}
}
}
这个改进解决了大部分请求的性能问题,但引入了新的复杂性:
- 缓存一致性:当我们在Redis中添加或删除了一个IP,
lua_shared_dict
中的缓存数据如何更新?它会在过期后才同步,这不满足我们“实时”的需求。 - 缓存穿透与惊群:对于一个从未访问过的新IP,所有worker进程的本地缓存都将是
nil
,它们会同时去查询Redis,造成瞬间的压力峰值。
构建主动更新机制:ngx.timer.at
与 Redis Pub/Sub
我们需要一个机制,当规则在Redis中发生变化时,能够主动通知所有OpenResty worker进程去更新它们的本地缓存。这才是实现“实时”的关键。
这个机制分为两部分:
- 消息通知:使用Redis的Pub/Sub功能。当管理后台更新规则时,向一个特定的channel(例如
waf_rule_updates
)发布一条消息。 - 后台监听与更新:在OpenResty的
init_worker_by_lua_block
阶段启动一个后台任务。这个阶段在每个worker进程启动时执行一次,非常适合用来创建轻量级的后台“协程”。这个协程将订阅Redis的channel,一旦收到消息,就去拉取最新的全量规则,并更新到lua_shared_dict
中。
下面是这个架构的完整实现。
graph TD subgraph "管理端 (Frontend/API)" A[前端Dashboard] -- "添加/删除规则" --> B(管理API) end subgraph "数据与通信层 (Redis)" B -- "1. HSET waf:rules:ip_blacklist ..." --> C{Redis} B -- "2. PUBLISH waf_rule_updates 'reload'" --> C end subgraph "网关层 (OpenResty)" C -- "3. 收到 'reload' 消息" --> D(后台Timer协程
每个Worker一个) D -- "4. HGETALL waf:rules:ip_blacklist" --> C D -- "5. 更新本地缓存" --> E(lua_shared_dict
所有Worker共享) F[用户请求] --> G(access_by_lua_block) G -- "6. 快速读取" --> E G -- "命中规则? 拒绝" --> H[403 Forbidden] G -- "未命中? 放行" --> I[后端服务] end
1. Nginx 配置 (nginx.conf
)
# /usr/local/openresty/nginx/conf/nginx.conf
worker_processes auto;
events {
worker_connections 1024;
}
http {
lua_package_path "/path/to/your/lua/libs/?.lua;;";
# 共享内存,用于缓存规则
lua_shared_dict waf_rules_cache 50m;
# 共享内存,用于worker间锁,防止惊群
lua_shared_dict waf_locks 1m;
# 在 master 进程加载,但在 fork worker 前执行
# 非常适合用来加载一些只读的、全局的Lua模块
init_by_lua_block {
-- 加载全局的规则更新器模块
-- 这样可以避免每个worker都重新加载一遍文件IO
require "core.waf_rule_updater"
}
# 每个 worker 进程启动时执行
init_worker_by_lua_block {
-- 启动后台的规则更新器
waf_rule_updater.run()
}
server {
listen 8888; # WAF 保护的服务端口
server_name _;
location / {
access_by_lua_file conf/lua/waf_access_handler.lua;
# ... proxy_pass, etc ...
proxy_pass http://127.0.0.1:8080; # 你的后端服务
}
}
server {
listen 9999; # WAF 管理 API 端口
server_name _;
location /api/rules {
# 只允许内网访问
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
content_by_lua_file conf/lua/waf_management_api.lua;
}
}
}
2. 规则更新器 (core/waf_rule_updater.lua
)
这是整个动态系统的核心。它在后台运行,负责监听Redis通知并刷新本地缓存。
-- /path/to/your/lua/libs/core/waf_rule_updater.lua
local redis = require "resty.redis"
local cjson = require "cjson.safe"
local _M = {}
-- 配置信息
local REDIS_HOST = "127.0.0.1"
local REDIS_PORT = 6379
local REDIS_DB = 0
local REDIS_SUB_CHANNEL = "waf_rule_updates"
local UPDATE_INTERVAL = 5 -- 秒,定时轮询的兜底间隔
local LOCK_KEY = "waf:update_lock"
local LOCK_TIMEOUT = 10 -- 秒,锁的超时时间
local function connect_redis()
local red = redis:new()
red:set_timeout(2000) -- 2 seconds
local ok, err = red:connect(REDIS_HOST, REDIS_PORT)
if not ok then
ngx.log(ngx.ERR, "waf_rule_updater: failed to connect to redis: ", err)
return nil
end
-- 如果有密码和DB选择
-- local res, err = red:auth("your_password")
-- red:select(REDIS_DB)
return red
end
-- 核心的规则加载函数
local function load_rules_from_redis()
local red = connect_redis()
if not red then
return false, "redis connection failed"
end
local waf_cache = ngx.shared.waf_rules_cache
-- 使用 pipeline 批量获取规则
red:init_pipeline()
red:hgetall("waf:rules:ip_blacklist")
red:hgetall("waf:rules:uri_blocklist")
-- 在这里可以添加更多规则类型
-- red:hgetall("waf:rules:rate_limit_config")
local results, err = red:commit_pipeline()
if not results or err then
ngx.log(ngx.ERR, "waf_rule_updater: pipeline failed: ", err)
red:set_keepalive(0) -- 关闭坏掉的连接
return false, "pipeline failed"
end
red:set_keepalive(10000, 100)
-- 在更新缓存前,先清空旧规则。在生产环境中,这可能需要更精细的增量更新。
-- 但对于一个简单的系统,全量刷新更易于实现和维护。
waf_cache:flush_all()
-- 1. 加载 IP 黑名单
local ip_blacklist_raw = results[1]
if ip_blacklist_raw ~= ngx.null then
for i = 1, #ip_blacklist_raw, 2 do
local ip = ip_blacklist_raw[i]
local metadata = cjson.decode(ip_blacklist_raw[i+1] or "{}") -- 值为JSON字符串
-- key: "ip_blacklist:1.2.3.4", value: "1" for simple check
-- value 也可以是序列化的元数据
waf_cache:set("ip_blacklist:" .. ip, cjson.encode(metadata), metadata.expire or 0)
end
end
-- 2. 加载 URI 黑名单
local uri_blocklist_raw = results[2]
if uri_blocklist_raw ~= ngx.null then
for i = 1, #uri_blocklist_raw, 2 do
local uri = uri_blocklist_raw[i]
local metadata = cjson.decode(uri_blocklist_raw[i+1] or "{}")
-- key: "uri_blocklist:/admin", value: "1"
waf_cache:set("uri_blocklist:" .. uri, "1", metadata.expire or 0)
end
end
ngx.log(ngx.INFO, "WAF rules successfully reloaded into shared dict.")
waf_cache:set("rules:last_update_time", ngx.time())
return true
end
local function background_worker()
-- 立即执行一次,确保 worker 启动时就有规则
local ok, err = load_rules_from_redis()
if not ok then
ngx.log(ngx.ERR, "Initial rule load failed: ", err)
end
-- 开始订阅
local red_sub = connect_redis()
if not red_sub then
ngx.log(ngx.ERR, "Cannot connect to redis for subscription, updater will not run.")
return
end
local ok, err = red_sub:subscribe(REDIS_SUB_CHANNEL)
if not ok then
ngx.log(ngx.ERR, "Failed to subscribe to channel: ", err)
return
end
ngx.log(ngx.NOTICE, "WAF rule updater subscribed to '", REDIS_SUB_CHANNEL, "' channel.")
while true do
-- 使用 60s 超时来读取,这样可以周期性地检查服务是否仍在运行
local res, err = red_sub:read_reply(60000)
if not res then
ngx.log(ngx.ERR, "Failed to read reply from subscription: ", err, ". Reconnecting...")
-- 断线重连逻辑
red_sub:close()
ngx.sleep(1) -- 等待1秒再重连
red_sub = connect_redis()
if red_sub then
red_sub:subscribe(REDIS_SUB_CHANNEL)
else
ngx.sleep(5) -- 连接失败则等待更长时间
end
else
-- res 是一个 table: {"message", "channel_name", "message_body"}
if type(res) == "table" and res[1] == "message" and res[3] == "reload" then
ngx.log(ngx.INFO, "Received 'reload' message. Updating WAF rules.")
-- 使用分布式锁,防止所有worker同时去更新,造成惊群效应
local locker = require "resty.lock"
local lock, err = locker:new("waf_locks")
if not lock then
ngx.log(ngx.ERR, "failed to create lock: ", err)
else
-- 尝试获取锁,等待时间为0,如果拿不到就算了,让拿到锁的worker去更新
local elapsed, err = lock:lock(LOCK_KEY)
if elapsed then
load_rules_from_redis()
lock:unlock()
else
-- 未获取到锁,说明其他worker正在更新,打印日志即可
ngx.log(ngx.INFO, "Another worker is updating rules, skipping.")
end
end
end
end
end
end
function _M.run()
-- ngx.timer.at 的第一个参数是延迟时间,0表示立即执行
-- 第二个参数是回调函数,第三个是函数的参数
-- 这里我们用它来启动一个不会阻塞主流程的后台 "协程"
local ok, err = ngx.timer.at(0, background_worker)
if not ok then
ngx.log(ngx.ERR, "failed to create WAF rule updater timer: ", err)
end
end
return _M
3. 请求处理逻辑 (waf_access_handler.lua
)
这是每个请求都会执行的检查逻辑,它必须极致地快。
-- /usr/local/openresty/nginx/conf/lua/waf_access_handler.lua
local waf_cache = ngx.shared.waf_rules_cache
-- 1. IP 黑名单检查
local client_ip = ngx.var.remote_addr
local ip_rule = waf_cache:get("ip_blacklist:" .. client_ip)
if ip_rule then
ngx.log(ngx.WARN, "BLOCK: IP ", client_ip, " is in blacklist. Rule: ", ip_rule)
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 2. URI 黑名单检查
local uri = ngx.var.uri
local uri_rule = waf_cache:get("uri_blocklist:" .. uri)
if uri_rule then
ngx.log(ngx.WARN, "BLOCK: URI ", uri, " is in blocklist.")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 3. 实现一个简单的速率限制 (基于IP)
-- 规则: 每10秒最多请求20次
local limit = 20
local window = 10 -- seconds
local key = "rate_limit:ip:" .. client_ip
-- incr 是原子操作,非常高效
local count, err = waf_cache:incr(key, 1, 0) -- 初始值为0
if not count and err == "not found" then
-- 第一次访问,设置初始值为1,并设置过期时间
local ok, err = waf_cache:add(key, 1, window)
if not ok and err == "exists" then
-- 并发情况,其他请求已经设置了
count, err = waf_cache:incr(key, 1, 0)
else
count = 1
end
end
if count and count > limit then
ngx.header["Retry-After"] = window
ngx.log(ngx.WARN, "BLOCK: Rate limit exceeded for IP ", client_ip, ". Count: ", count)
return ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
end
-- 所有检查通过,请求继续
4. 管理 API (waf_management_api.lua
)
这是提供给前端控制台调用的接口,负责将规则写入Redis并发布通知。
-- /usr/local/openresty/nginx/conf/lua/waf_management_api.lua
local redis = require "resty.redis"
local cjson = require "cjson.safe"
local REDIS_HOST = "127.0.0.1"
local REDIS_PORT = 6379
local REDIS_SUB_CHANNEL = "waf_rule_updates"
ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say('{"error": "request body is required"}')
return
end
local data, err = cjson.decode(body)
if not data then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say('{"error": "invalid json format", "detail": "' .. (err or "") .. '"}')
return
end
local action = data.action -- "add", "delete"
local rule_type = data.type -- "ip_blacklist", "uri_blocklist"
local value = data.value
local metadata = data.metadata or {} -- 比如过期时间, 备注等
if not (action and rule_type and value) then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say('{"error": "action, type, and value are required"}')
return
end
-- 连接 Redis
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect(REDIS_HOST, REDIS_PORT)
if not ok then
ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.say('{"error": "cannot connect to redis"}')
return
end
local redis_key = "waf:rules:" .. rule_type
local success = false
local message = ""
if action == "add" then
local res, err = red:hset(redis_key, value, cjson.encode(metadata))
if res then
success = true
message = "Rule added successfully."
else
message = "Failed to add rule: " .. (err or "unknown")
end
elseif action == "delete" then
local res, err = red:hdel(redis_key, value)
if res then
success = true
message = "Rule deleted successfully."
else
message = "Failed to delete rule: " .. (err or "unknown")
end
else
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say('{"error": "invalid action"}')
red:set_keepalive(0)
return
end
if success then
-- 规则变更成功,发布通知
red:publish(REDIS_SUB_CHANNEL, "reload")
end
red:set_keepalive(10000, 100)
if success then
ngx.status = ngx.HTTP_OK
ngx.say(cjson.encode({status = "ok", message = message}))
else
ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.say(cjson.encode({status = "error", message = message}))
end
前端控制平面的角色
至此,后端的核心逻辑已经完成。前端的角色变得非常清晰:它是一个纯粹的API消费者。一个简单的React或Vue应用,包含一个表单用于添加规则(IP、URI、过期时间等),一个列表用于展示和删除当前生效的规则。
当用户点击“添加IP黑名单”时,前端会发起一个POST请求:POST /api/rules
Body:
{
"action": "add",
"type": "ip_blacklist",
"value": "192.168.1.100",
"metadata": {
"reason": "Suspicious scanning activity",
"expire": 3600,
"operator": "admin"
}
}
这个请求由waf_management_api.lua
处理,写入Redis,然后发布reload
消息。几乎在瞬间,所有OpenResty worker的后台协程都会收到通知,通过锁机制确保只有一个worker去拉取新规则,并刷新到共享内存中。下一秒,来自192.168.1.100
的任何请求都将被waf_access_handler.lua
直接拦截。
方案的局限性与未来展望
这个架构虽然实现了核心目标,但在生产环境中仍有需要考量的地方。它是一个起点,而非终点。
规则复杂性:当前的规则引擎仅支持精确匹配IP和URI。一个真正的WAF需要支持CIDR(IP段)、正则表达式、请求头/体内容匹配等复杂逻辑。扩展Lua脚本可以实现这些,但会增加处理的CPU开销,需要在性能和功能间做权衡。
全量更新的风险:当规则集非常庞大时(例如几十万条IP黑名单),每次都全量加载和刷新
lua_shared_dict
可能会导致短暂的CPU尖峰。更优化的方案是采用增量更新,即Redis的通知消息中携带具体的变更内容({action: "add", type: "ip", value: "x.x.x.x"}
),后台协程只对本地缓存进行增量修改。可观测性:我们记录了日志,但这还不够。需要将拦截事件、规则命中率等指标对接到Prometheus或类似监控系统,以便进行告警和分析。这可以通过在Lua中调用第三方库(如
lua-resty-statsd
)来实现。集群部署:当前方案在OpenResty集群中可以良好工作,因为每个节点都独立订阅Redis并更新自己的本地缓存。然而,需要保证Redis Pub/Sub的可靠性。对于更高要求的场景,可能会考虑使用更健壮的消息队列如Kafka。
这个项目展示了如何将前端、Lua和防火墙(WAF)这三个看似不相关的技术点有机结合,创造出一个灵活、高效且对业务透明的动态安全防护系统。它剥离了传统WAF笨重的规则管理方式,将控制权交给了可以快速响应的Web界面,这在现代DevSecOps流程中具有实际价值。