使用 Lua 和 OpenResty 构建一个可由前端实时管理的动态 Web 防火墙


静态的防火墙规则集正在成为我们运维流程中的一个瓶颈。每次更新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;
        }
    }
}

这个改进解决了大部分请求的性能问题,但引入了新的复杂性:

  1. 缓存一致性:当我们在Redis中添加或删除了一个IP,lua_shared_dict中的缓存数据如何更新?它会在过期后才同步,这不满足我们“实时”的需求。
  2. 缓存穿透与惊群:对于一个从未访问过的新IP,所有worker进程的本地缓存都将是nil,它们会同时去查询Redis,造成瞬间的压力峰值。

构建主动更新机制:ngx.timer.at 与 Redis Pub/Sub

我们需要一个机制,当规则在Redis中发生变化时,能够主动通知所有OpenResty worker进程去更新它们的本地缓存。这才是实现“实时”的关键。

这个机制分为两部分:

  1. 消息通知:使用Redis的Pub/Sub功能。当管理后台更新规则时,向一个特定的channel(例如 waf_rule_updates)发布一条消息。
  2. 后台监听与更新:在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直接拦截。

方案的局限性与未来展望

这个架构虽然实现了核心目标,但在生产环境中仍有需要考量的地方。它是一个起点,而非终点。

  1. 规则复杂性:当前的规则引擎仅支持精确匹配IP和URI。一个真正的WAF需要支持CIDR(IP段)、正则表达式、请求头/体内容匹配等复杂逻辑。扩展Lua脚本可以实现这些,但会增加处理的CPU开销,需要在性能和功能间做权衡。

  2. 全量更新的风险:当规则集非常庞大时(例如几十万条IP黑名单),每次都全量加载和刷新lua_shared_dict可能会导致短暂的CPU尖峰。更优化的方案是采用增量更新,即Redis的通知消息中携带具体的变更内容({action: "add", type: "ip", value: "x.x.x.x"}),后台协程只对本地缓存进行增量修改。

  3. 可观测性:我们记录了日志,但这还不够。需要将拦截事件、规则命中率等指标对接到Prometheus或类似监控系统,以便进行告警和分析。这可以通过在Lua中调用第三方库(如lua-resty-statsd)来实现。

  4. 集群部署:当前方案在OpenResty集群中可以良好工作,因为每个节点都独立订阅Redis并更新自己的本地缓存。然而,需要保证Redis Pub/Sub的可靠性。对于更高要求的场景,可能会考虑使用更健壮的消息队列如Kafka。

这个项目展示了如何将前端、Lua和防火墙(WAF)这三个看似不相关的技术点有机结合,创造出一个灵活、高效且对业务透明的动态安全防护系统。它剥离了传统WAF笨重的规则管理方式,将控制权交给了可以快速响应的Web界面,这在现代DevSecOps流程中具有实际价值。


  目录