最近有反馈官网访问异常卡顿,查看 Portainer 面板发现官网后台的后端服务所依赖的 jdk、mysql 等 docker 容器在不断的奔溃重建,查询发现奔溃日志,交由后端排查。
后端反馈服务器磁盘不足导致 mysql 无法启动,后端访问连不上数据库导致的。
查询磁盘占用,发现官网的 nginx 日志文件占用近 20GB,删除后官网后端访问正常运行。
日志切分与压缩
为了避免日志持续增长占用磁盘空间,需要对日志进行管理,这里使用 linux 系统自带的 logrotate 日志管理工具实现自动切割、压缩与清理。
创建 logrotate 配置文件:
1vim /etc/logrotate.d/nginx 2
写入特定配置:
1path/to/log/*.log { 2 size 500M # 达到指定大小时轮转一次 3 missingok # 日志文件缺失时不报错 4 rotate 14 # 保留最近 14 个日志文件(约 2 周) 5 compress # 压缩旧日志(gzip 格式) 6 delaycompress # 延迟压缩(下次轮转时压缩上一次日志,避免影响当前日志读写) 7 notifempty # 日志为空时不轮转 8 create 0644 root root # 新建日志文件的权限和属主 9 sharedscripts 10 postrotate # 轮转后执行的命令,通知Nginx重新打开日志 11 # 检查宝塔环境 Nginx 的 PID 文件是否存在且可读 12 if [ -f /www/server/nginx/logs/nginx.pid ] && [ -r /www/server/nginx/logs/nginx.pid ]; then 13 # 向正确的 Nginx 实例发送 USR1 信号 14 kill -USR1 $(cat /www/server/nginx/logs/nginx.pid) >/dev/null 2>&1 || true 15 else 16 # 记录警告日志(可选,需 root 权限) 17 logger -t logrotate "宝塔 Nginx PID 文件缺失,日志切割可能失败" 18 fi 19 endscript 20} 21
手动触发一次以查看效果:
1logrotate -f /etc/logrotate.d/nginx # -f 强制执行 2
排查日志
对日志进行管理后,官网访问仍旧缓慢,查看 nginx 日志发现日志增长飞速,且服务器监控面板显示外网出带宽使用率每天 24 小时都在 85% 及以上:
猜测可能遭遇流量攻击,在 nginx 配置中添加多项简易的代码,校验请求是否异常,其中下列代码可以拦截大部分异常请求,外网出带宽使用率回落至正常状态:
1if ($http_referer = "") { 2 return 403; 3} 4
Referer 记录请求的来源地址,浏览器一般会在请求中自动加上 Referer 头,通常伪装浏览器请求时都会伪造此请求头,但此次的攻击者显然是忽略。
话虽如此,我们却无法仅通过是否存在 referer 头来判断请求是否正常,因为浏览器刷新时、从书签访问时都不会携带此请求头,且为了隐私考虑,加载外部资源(图片、js、css)时可以明确禁止发送 referer,如果一刀切的话,官网运行会收到影响。
封禁固定 IP
为了官网尽快恢复访问,需要一个临时解决方案,分析 nginx 日志后,决定采用简易的策略记录查找异常 IP 并封禁,nginx 日志格式如下:
1IP - - [TIME] "Request_Method URI HTTP_Version" Status ContentLength "-" "User-Agent" 2
注意需要有足够数量的日志,能够反映出异常 IP,这里的日志是 2-3 天的,产生了约 3GB 的日志文件,约 1250 万次请求。
策略:
- 以 IP + URI + User-Agent 为 key,记录出现的次数
- 由于设置了浏览器缓存,正常访问不应该在这么短的时间范围内多次重复请求
- 针对 IP + User-Agent 判断不同 URI 多次请求的次数,大于阈值时判定异常
最终筛选出来的 IP 通过 nginx 配置封禁,这里筛选出了一万多个 IP。
观察筛选后的结果,发现很多 IP 的网段(115.115.2.89, 115.115.2.90)相同,可以归属于同一组下;优化筛选逻辑:上一步筛选过的结果进行二次筛选,提取 IP 的网段,得到 xx.xx.0.0/16 的结果(115.115.0.0/16),将这一个网段下的所有请求封禁,这里分组后只剩下 100 多组。
动态限流(尝试)
封禁异常 IP 网段后,服务器流量基本正常,但此非长久之计,不能保证后续不会有新的 IP 攻击出现,且封禁网段的做法有概率影响到正常用户。
为了长久发展,需要实现动态限流策略;使用 nginx 模块 limit_req 与 limit_conn 限制特定时间内的请求数量与单 IP 并发数量:
1http { 2 # ① 请求频率限制:10M 内存存储 IP 状态,限制每秒 10 个请求 3 limit_req_zone $binary_remote_addr zone=ip_limit:10m rate=10r/s; 4 # ② 并发连接限制:10M 内存存储 IP 连接数 5 limit_conn_zone $binary_remote_addr zone=conn_limit:10m; 6 7 server { 8 listen 80; 9 server_name example.com; 10 11 location / { 12 # 应用请求频率限制:允许突发 5 个请求,超出立即返回 429 13 limit_req zone=ip_limit burst=5 nodelay; 14 # 限制单 IP 最大并发连接数为 10 15 limit_conn conn_limit 10; 16 17 # 自定义限流响应状态码(默认 503,推荐 429 Too Many Requests) 18 limit_req_status 429; 19 limit_conn_status 429; 20 21 proxy_pass http://backend; 22 } 23 24 # 自定义 429 错误页面 25 error_page 429 /429.html; 26 location = /429.html { 27 root /var/www/html; 28 internal; # 仅内部访问 29 } 30 } 31} 32
实际测试效果不佳,原因:官网服务器带宽较小(5Mbps),设置较大的连接数与并发数时,起不到限制效果;设置较小的连接数与并发数时,会影响正常用户的访问,因为无缓存加载网页时会请求较多的 js、css、图片资源。
动态限流-策略 1
为了更精准的识别异常 IP,需要更灵活的逻辑控制,可以使用添加过 lua-nginx-module 模块的 nginx,通过组合 nginx + lua(逻辑控制) + redis(缓存) 实现复杂限流策略。
策略 1:
- IP + URI + User-Agent 为 key,记录访问次数
- 由于设置了浏览器缓存,正常访问不应该在这么短的时间范围内多次重复请求
- 如果 key 对应的访问次数大于阈值,直接返回 429
1http { 2 # 加载 Lua 模块 3 lua_package_path "/path/to/lua/?.lua;;"; 4 5 server { 6 listen 80; 7 server_name yourdomain.com; 8 9 # 首页及动态内容 10 location / { 11 access_by_lua_file /etc/nginx/lua/limit.lua; 12 proxy_pass http://backend; 13 } 14 } 15} 16
1local redis = require "resty.redis" 2local red = redis:new() 3red:set_timeout(1000) -- 1 秒超时 4 5-- 连接 Redis(替换为实际地址) 6local ok, err = red:connect("127.0.0.1", 6379) 7if not ok then 8 ngx.log(ngx.ERR, "Redis 连接失败: ", err) 9 return ngx.exit(500) 10end 11 12-- 构建复合键:IP + User-Agent + URI 13local ip = ngx.var.binary_remote_addr 14local ua = ngx.var.http_user_agent or "unknown" 15local uri = ngx.var.uri 16local key = "req:" .. ip .. ":" .. ngx.md5(ua) .. ":" .. uri 17 18-- 10 秒内最多允许 2 次请求(缓存有效期内重复请求视为异常) 19local limit = 2 20local expire = 10 21 22-- Redis 原子操作:计数 + 过期时间设置 23local res, err = red:eval([[ 24 local count = redis.call('incr', KEYS[1]) 25 if count == 1 then 26 redis.call('expire', KEYS[1], ARGV[2]) 27 end 28 return count 29]], 1, key, limit, expire) 30 31-- 判断是否超限 32if res and tonumber(res) > limit then 33 ngx.log(ngx.WARN, "触发限流: ", key) 34 return ngx.exit(429) -- 返回 429 Too Many Requests 35end 36 37-- 保持 Redis 连接池 38red:set_keepalive(10000, 100) -- 空闲超时 10 秒,池大小 100 39
效果不佳,问题在于对于每个 IP 不同的 URI 默认允许访问一次,在大批量的 IP 流量攻击下,外网出带宽使用率仍旧巨高不下。
动态限流-策略 2
针对上述痛点,思考出了以 Cookie 标识正常用户的策略:
- 接收到访问时,查询 IP + User-Agent 的 key 是否在 redis 中
- 如果存在,要求此次请求必须包含指定的 cookie,如果不包含,直接 403
- 如果不存在,生成 cookie 并 set-cookie,同时将 cookie 缓存至 redis
1local redis = require "resty.redis" 2local str = require "resty.string" 3local hmac = require "resty.hmac" 4 5-- 配置参数 6local config = { 7 redis_host = "127.0.0.1", 8 redis_port = 6379, 9 expire_seconds = 518400, -- 6 天 = 6 * 24 * 3600 秒 10 token = "xss_t90", 11 pool_size = 100, 12 idle_timeout = 30000 13} 14 15-- 获取客户端真实 IP 16local function get_client_ip() 17 local ip = ngx.req.get_headers()["X-Real-IP"] 18 19 if not ip then 20 ip = ngx.req.get_headers()["X-Forwarded-For"] 21 end 22 23 if not ip then 24 ip = ngx.var.remote_addr 25 end 26 27 return ip or "unknown" 28end 29 30-- 连接 Redis 并设置连接池 31local function connect_redis() 32 local red = redis:new() 33 34 red:set_timeout(1000) -- 1 秒超时 35 36 37 local ok, err = red:connect(config.redis_host, config.redis_port) 38 if not ok then 39 ngx.log(ngx.ERR, "Redis 连接失败: ", err) 40 return nil, err 41 end 42 43 -- 复用连接池 44 local count, err = red:get_reused_times() 45 if 0 == count then 46 -- 新连接需认证(如有密码) 47 -- red:auth("your_redis_password") 48 elseif err then 49 ngx.log(ngx.ERR, "Redis get reused times failed: ", err) 50 end 51 52 return red 53end 54 55-- 生成加密令牌 56local function generate_token() 57 local client_ip = get_client_ip() 58 local user_agent = ngx.var.http_user_agent or "unknown" 59 local secret = config.token 60 local timestamp = ngx.time() 61 local nonce = math.random(100000, 999999) -- 6 位随机数 62 local plaintext = client_ip .. user_agent .. timestamp .. nonce 63 64 -- HMAC-SHA256 加密 65 local hmac_obj = hmac:new(secret, hmac.ALGOS.SHA256) 66 hmac_obj:update(plaintext) 67 68 return str.to_hex(hmac_obj:final()), timestamp 69end 70 71-- 主逻辑 72local function main() 73 -- 1. 获取请求标识要素 74 local client_ip = get_client_ip() 75 local user_agent = ngx.var.http_user_agent or "unknown" 76 77 -- 2. 生成唯一键(IP + UA 哈希) 78 local redis_key = "os_visit:" .. client_ip .. ":" .. ngx.md5(user_agent) -- 用 MD5 哈希 UA 避免过长 79 80 -- 4. 连接 Redis 并获取计数 81 local red = connect_redis() 82 if not red then 83 return -- Redis 故障时放行,避免影响正常访问 84 end 85 86 local stored_token, err = red:get(redis_key) 87 if err then 88 ngx.log(ngx.ERR, "Redis get stored_token failed: ", err) 89 red:set_keepalive(config.idle_timeout, config.pool_size) 90 return 91 end 92 93 -- 2. 无存储令牌:生成新令牌并设置 Cookie 94 if stored_token == ngx.null then 95 local token, timestamp = generate_token() 96 97 -- 存储令牌到 Redis 98 red:setex(redis_key, config.expire_seconds, token) 99 100 -- 设置 Cookie (HttpOnly + Secure,生产环境建议开启 Secure) 101 ngx.header["Set-Cookie"] = string.format( 102 "os_visit_token=%s; HttpOnly; SameSite=Strict; Path=/; Max-Age=%d", 103 token, 104 config.expire_seconds 105 ) 106 107 red:set_keepalive(config.idle_timeout, config.pool_size) 108 109 return 110 end 111 112 red:set_keepalive(config.idle_timeout, config.pool_size) -- 提前归还连接池 113 114 local client_token = ngx.var.cookie_os_visit_token 115 116 if not client_token then 117 118 ngx.log(ngx.ERR, "异常用户, Cookie 不存在") 119 120 return ngx.exit(403) 121 122 elseif client_token ~= stored_token then 123 ngx.log(ngx.ERR, "异常用户, Cookie 不匹配") 124 125 -- Cookie 不匹配:拒绝访问 126 ngx.exit(403) 127 end 128 129 -- 7. 正常请求,继续处理 130end 131 132-- 执行主逻辑 133main() 134
预期的结果是攻击方没有处理 cookie 携带的逻辑,当 IP 首次访问时设置 Cookie,后续资源请求未携带 cookie 而被拦截;实际结果是 Cookie 过于常见,大部分工具都会自动处理他。
动态限流-策略 3
提出人机验证的想法,要求用户访问时进行真人验证,通过则允许访问,未实践,因为用户体验较差。
动态限流-策略 4
观察日志,发现异常 IP 都使用 HTTP/1.1 的协议版本,而正常用户使用的现代浏览器基本使用 HTTP/2.0 协议版本,基于此,实现多级校验策略:
- 如果 HTTP 协议版本大于等于 2.0, 或 Referer 请求头不为空,不做后续步骤直接放行
- 针对 HTTP 协议版本小于 2.0, 或 Referer 请求头为空的,执行下列逻辑
- 判断当前 IP 是否存在于白名单列表,存在直接放行,反之执行下列逻辑
- 提前当前 IP 的网段,(115.115.2.80 -> 115.115),查询此网段下的 IP 数量,大于阈值时,重定向至人机校验页面,小于阈值时允许访问
- 将此 IP 添加到网段集合
人机校验逻辑:
- 生成 4 位随机字符串,缓存至 redis,key = IP + User-Agent
- 根据字符串生成图片,展示在前端页面
- 用户输入并提交
- 验证是否通过,通过则将此 IP + User-Agent 添加进白名单中,设置较长的过期时间
此逻辑的优点:
- 根据现有日志分析,明确正常用户的特征,减少正常用户的操作步骤
- 基于网段分组的封禁,网段组内 IP 大于阈值时,后续新的 IP 进来直接重定向至人机校验,较少了服务器压力
- 使用人机校验添加兜底处理,避免正常用户被误封,且校验通过后较长一段时间内白名单通行,避免了多次校验
完整代码:
1http { 2 server { 3 lua_package_path "/www/server/nginx/lib/lua/?.lua;;"; 4 lua_package_cpath "/www/server/nginx/lib/lua/?.so;;"; 5 6 # 代理与校验 7 location / { 8 access_by_lua_file /usr/local/openresty/nginx/lua/auth.lua; 9 proxy_pass http://xxxx:xx; 10 } 11 12 # 验证码图片生成接口 13 location /captcha-image { 14 content_by_lua_file /usr/local/openresty/nginx/lua/generate_captcha.lua; 15 } 16 17 # 验证码验证接口 18 location /captcha-verify { 19 content_by_lua_file /usr/local/openresty/nginx/lua/verify_captcha.lua; 20 } 21 22 # 验证码展示页面 23 location = /captcha { 24 alias /www/server/nginx/html/verify.html; 25 default_type text/html; 26 charset utf-8; 27 expires -1; 28 add_header Cache-Control "no-store, no-cache, must-revalidate"; 29 } 30 } 31} 32
auth.lua:
1local redis = require "resty.redis" 2 3-- 配置参数(可根据业务调整) 4local config = { 5 redis_host = "127.0.0.1", 6 redis_port = 6379, 7 whitelist_key = "os_whitelist", 8 subnet_threshold = 2, 9 subnet_window = 1209600 -- 14 天 10} 11 12-- Redis 连接池管理 13local function close_redis(red) 14 if not red then return end 15 16 local ok, err = red:set_keepalive(10000, 100) -- 10 秒空闲超时,池大小 100 17 if not ok then 18 ngx.log(ngx.NOTICE, "Redis keepalive error: ", err) 19 red:close() 20 end 21end 22 23-- 获取客户端真实 IP(支持代理场景) 24local function get_client_ip() 25 local ip = ngx.req.get_headers()["X-Real-IP"] 26 27 if not ip then 28 ip = ngx.req.get_headers()["X-Forwarded-For"] or ngx.var.remote_addr 29 end 30 31 return ip:match("^[%d%.]+") -- 提取首个 IP(避免代理链干扰) 32end 33 34-- 连接 Redis 并设置连接池 35local function connect_redis() 36 local red = redis:new() 37 red:set_timeout(1000) -- 1 秒超时 38 39 local ok, err = red:connect(config.redis_host, config.redis_port) 40 41 if not ok then 42 ngx.log(ngx.NOTICE, "Redis 连接失败: ", err) 43 return nil, err 44 end 45 46 -- 复用连接池 47 local count, err = red:get_reused_times() 48 49 if count == 0 then 50 -- ngx.log(ngx.NOTICE, "Redis 无法连接, 需要密码") 51 elseif err then 52 ngx.log(ngx.NOTICE, "Redis 复用连接失败: ", err) 53 end 54 55 return red 56end 57 58-- 主逻辑 59local function main() 60 local red = connect_redis() 61 62 if not red then 63 return -- Redis 故障时放行,避免影响正常访问 64 end 65 66 -- 1. 协议与 Referer 校验 67 local http_version = ngx.req.http_version() 68 local referer = ngx.var.http_referer or "" 69 70 if http_version >= 2.0 or referer ~= "" then -- and & or 71 ngx.log(ngx.NOTICE, "HTTP 版本大于等于 2 或 referer 不为空, 默认放行, ", client_ip) 72 73 return close_redis(red) -- 直接放行 74 end 75 76 -- 1. 白名单检查(最高优先级) 77 local client_ip = get_client_ip() 78 local user_agent = ngx.var.http_user_agent or "unknown" 79 local white_key = config.whitelist_key .. ":" .. client_ip .. "_" .. ngx.md5(user_agent) 80 local is_white, err = red:get(white_key) 81 82 if is_white and is_white ~= ngx.null then 83 return close_redis(red) -- 白名单直接放行 84 end 85 86 -- 3. 网段级访问控制(提取 IP 前两段) 87 local ip_segments = {} 88 89 for seg in string.gmatch(client_ip, "%d+") do 90 table.insert(ip_segments, seg) 91 end 92 93 local subnet_key = "os_visit:" .. ip_segments[1] .. "." .. ip_segments[2] 94 local full_ip_key = subnet_key .. ":" .. client_ip 95 96 -- 3.1 检查网段 IP 数量 97 local subnet_ip_count, err = red:scard(subnet_key) -- SCARD 获取集合元素数 98 99 -- 3.2 添加新 IP 到网段集合 100 local is_new_ip, err = red:sadd(subnet_key, client_ip) -- SADD 返回 1 表示新添加 101 if is_new_ip == 1 then 102 red:expire(subnet_key, config.subnet_window) -- 重置网段过期时间 103 end 104 105 if subnet_ip_count and subnet_ip_count >= config.subnet_threshold then 106 -- 网段超限,触发人机验证 107 close_redis(red) 108 return ngx.redirect("/captcha") 109 end 110 111 close_redis(red) 112end 113 114-- 执行主逻辑 115main() 116
verify_captcha.lua:
1local redis = require "resty.redis" 2 3local config = { 4 redis_host = "127.0.0.1", 5 redis_port = 6379, 6 whitelist_expire = 1036800, -- 白名单有效期(12 天) 7 whitelist_key = "os_whitelist", 8 auth_key = "os_auth" 9} 10 11-- Redis 连接池管理 12local function close_redis(red) 13 if not red then return end 14 15 local ok, err = red:set_keepalive(10000, 100) -- 10 秒空闲超时,池大小 100 16 if not ok then 17 ngx.log(ngx.NOTICE, "Redis keepalive error: ", err) 18 red:close() 19 end 20end 21 22-- 获取客户端真实 IP(支持代理场景) 23local function get_client_ip() 24 local ip = ngx.req.get_headers()["X-Real-IP"] 25 26 if not ip then 27 ip = ngx.req.get_headers()["X-Forwarded-For"] or ngx.var.remote_addr 28 end 29 30 return ip:match("^[%d%.]+") -- 提取首个 IP(避免代理链干扰) 31end 32 33-- 连接 Redis 并设置连接池 34local function connect_redis() 35 local red = redis:new() 36 red:set_timeout(1000) -- 1 秒超时 37 38 local ok, err = red:connect(config.redis_host, config.redis_port) 39 40 if not ok then 41 ngx.log(ngx.NOTICE, "Redis 连接失败: ", err) 42 return nil, err 43 end 44 45 -- 复用连接池 46 local count, err = red:get_reused_times() 47 48 if count == 0 then 49 -- ngx.log(ngx.NOTICE, "Redis 无法连接, 需要密码") 50 elseif err then 51 ngx.log(ngx.NOTICE, "Redis 复用连接失败: ", err) 52 end 53 54 return red 55end 56 57-- 获取请求参数 58local args = ngx.req.get_uri_args() 59local authCode = args.code or "" 60 61if authCode == "" then 62 ngx.status = 400 63 ngx.say('{"status": "error", "msg": "Missing parameters"}') 64 return 65end 66 67-- 主逻辑 68local red = connect_redis() 69 70if not red then 71 ngx.status = 500 72 ngx.say('{"status": "error", "msg": "Service exception, please try again later"}') 73 74 return 75end 76 77-- 生成唯一标识与验证码 78local client_ip = get_client_ip() 79local user_agent = ngx.var.http_user_agent or "unknown" 80local redis_key = config.auth_key .. ":" .. client_ip .. "_" .. ngx.md5(user_agent) 81local white_key = config.whitelist_key .. ":" .. client_ip .. "_" .. ngx.md5(user_agent) 82 83local is_white, err = red:get(white_key) 84 85if is_white and is_white ~= ngx.null then -- 白名单禁止多次验证 86 return ngx.exit(403) 87end 88 89-- 验证验证码 90local stored_code, err = red:get(redis_key) 91 92if stored_code and stored_code ~= ngx.null then 93 -- 不区分大小写验证(增强用户体验) 94 if string.upper(authCode) == string.upper(stored_code) then 95 red:del(redis_key) -- 验证通过后删除缓存 96 97 local white_key = config.whitelist_key .. ":" .. client_ip .. "_" .. ngx.md5(user_agent) 98 red:setex(white_key, config.whitelist_expire, "1") 99 100 ngx.say('{"status": "success", "msg": "OK"}') 101 else 102 ngx.say('{"status": "error", "msg": "Incorrect captcha"}') 103 end 104else 105 ngx.say('{"status": "error", "msg": "Captcha expired or invalid"}') 106end 107 108close_redis(red) 109
generate_captcha.lua:
1local gd = require "gd" 2local redis = require "resty.redis" 3local str = require "resty.string" 4 5-- 配置参数 6local config = { 7 width = 200, -- 图像宽度 8 height = 120, -- 图像高度 9 length = 4, -- 验证码长度 10 redis_host = "127.0.0.1", 11 redis_port = 6379, 12 whitelist_key = "os_whitelist", 13 auth_key = "os_auth", 14 expire = 600 15} 16 17-- Redis 连接池管理 18local function close_redis(red) 19 if not red then return end 20 21 local ok, err = red:set_keepalive(10000, 100) -- 10 秒空闲超时,池大小 100 22 if not ok then 23 ngx.log(ngx.NOTICE, "Redis keepalive error: ", err) 24 red:close() 25 end 26end 27 28-- 获取客户端真实 IP(支持代理场景) 29local function get_client_ip() 30 local ip = ngx.req.get_headers()["X-Real-IP"] 31 32 if not ip then 33 ip = ngx.req.get_headers()["X-Forwarded-For"] or ngx.var.remote_addr 34 end 35 36 return ip:match("^[%d%.]+") -- 提取首个 IP(避免代理链干扰) 37end 38 39-- 连接 Redis 并设置连接池 40local function connect_redis() 41 local red = redis:new() 42 red:set_timeout(1000) -- 1 秒超时 43 44 local ok, err = red:connect(config.redis_host, config.redis_port) 45 46 if not ok then 47 ngx.log(ngx.NOTICE, "Redis 连接失败: ", err) 48 return nil, err 49 end 50 51 -- 复用连接池 52 local count, err = red:get_reused_times() 53 54 if count == 0 then 55 -- ngx.log(ngx.NOTICE, "Redis 无法连接, 需要密码") 56 elseif err then 57 ngx.log(ngx.NOTICE, "Redis 复用连接失败: ", err) 58 end 59 60 return red 61end 62 63-- 生成随机验证码字符串 64local function generate_code() 65 local chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz0123456789" 66 local code = "" 67 68 math.randomseed(os.time() + ngx.worker.pid()) 69 70 for _ = 1, config.length do 71 local idx = math.random(1, #chars) 72 code = code .. chars:sub(idx, idx) 73 end 74 75 return code 76end 77 78-- 创建带干扰的验证码图像 79local function create_image(code) 80 local img = gd.createTrueColor(config.width, config.height) 81 local white = img:colorAllocate(255, 255, 255) 82 local black = img:colorAllocate(0, 0, 0) 83 84 img:filledRectangle(0, 0, config.width, config.height, white) 85 86 -- 绘制随机字符(带旋转和随机颜色) 87 for i = 1, #code do 88 local c = code:sub(i, i) 89 local color = img:colorAllocate( 90 math.random(30, 100), -- R 91 math.random(30, 100), -- G 92 math.random(30, 100) -- B 93 ) 94 -- 随机字体大小与旋转角度 95 local font_size = math.random(26, 32) 96 local x = 35 + (i - 1) * 30 + math.random(-8, 8) 97 local y = (config.height / 2 + 14) + math.random(-8, 8) 98 99 img:stringFT( 100 color, 101 "/usr/share/fonts/xxx/xxx.ttf", 102 font_size, 103 0, 104 x, 105 y, 106 c 107 ) 108 end 109 110 -- 添加噪点(100 个随机像素) 111 for _ = 1, 100 do 112 local color = img:colorAllocate( 113 math.random(0, 255), 114 math.random(0, 255), 115 math.random(0, 255) 116 ) 117 118 img:setPixel( 119 math.random(0, config.width), 120 math.random(0, config.height), 121 color 122 ) 123 end 124 125 return img 126end 127 128-- 主逻辑 129local red = connect_redis() 130 131if not red then 132 return ngx.exit(500) -- 生成图片失败 133end 134 135-- 生成唯一标识与验证码 136local client_ip = get_client_ip() 137local user_agent = ngx.var.http_user_agent or "unknown" 138local redis_key = config.auth_key .. ":" .. client_ip .. "_" .. ngx.md5(user_agent) 139local white_key = config.whitelist_key .. ":" .. client_ip .. "_" .. ngx.md5(user_agent) 140 141local is_white, err = red:get(white_key) 142 143if is_white and is_white ~= ngx.null then -- 白名单禁止多次获取图像 144 return ngx.exit(403) 145end 146 147local code = generate_code() 148local img = create_image(code) 149 150-- 存储验证码到 Redis 151red:setex(redis_key, config.expire, code) 152close_redis() 153 154-- 输出图像响应 155ngx.header["Content-Type"] = "image/png" 156ngx.print(img:pngStrEx(6)) -- 返回图片数据 157
verify.html:
1<!DOCTYPE html> 2<html lang="zh"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>系统校验/System check</title> 7 8 <style> 9 :root { 10 --color-text: #333; 11 --color-primary: #53aed6; 12 --color-info: #909399; 13 --color-background: #fff; 14 --radis-base: 4px; 15 --color-text-revert: #fff; 16 --color-danger: #f56c6c; 17 } 18 19 @media (prefers-color-scheme: dark) { 20 :root { 21 --color-text: rgba(200, 200, 200); 22 --color-background: #000; 23 --color-primary: #405761; 24 --color-text-revert: rgb(200, 200, 200); 25 --color-info: #909399; 26 } 27 } 28 29 * { 30 margin: 0; 31 padding: 0; 32 box-sizing: border-box; 33 } 34 35 body { 36 font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 37 font-size: 15px; 38 font-weight: 400; 39 color: var(--color-text); 40 color-scheme: light dark; 41 max-width: 100vw; 42 overflow-x: hidden; 43 background-color: var(--color-background); 44 transition: background 0.3s ease-out; 45 text-rendering: optimizelegibility; 46 -webkit-font-smoothing: antialiased; 47 -moz-osx-font-smoothing: grayscale; 48 line-height: 1; 49 } 50 51 button { 52 padding: 5px 10px; 53 outline: none; 54 border: none; 55 background-color: transparent; 56 cursor: pointer; 57 border-radius: var(--radis-base); 58 transition: opacity 0.3 ease-in; 59 } 60 61 .button:hover { 62 opacity: 0.8; 63 } 64 65 .button:active { 66 opacity: 0.7; 67 } 68 69 button.primary { 70 background-color: var(--color-primary); 71 color: var(--color-text-revert); 72 } 73 74 button.info { 75 background-color: var(--color-info); 76 color: #fff; 77 } 78 79 .main { 80 padding: 20px; 81 width: 100%; 82 } 83 84 .title { 85 font-size: 17px; 86 font-weight: 500; 87 } 88 89 .img-wrapper { 90 margin-top: 16px; 91 } 92 93 #captcha-img { 94 vertical-align: bottom; 95 border: 1px solid #ddd; 96 border-radius: var(--radis-base); 97 } 98 99 .input-wrapper { 100 margin-top: 12px; 101 } 102 103 #captcha-input { 104 padding: 0 5px; 105 height: 28px; 106 line-height: 28px; 107 outline: none; 108 border-radius: var(--radis-base); 109 border: 1px solid #ccc; 110 transition: border 0.3 ease-in; 111 } 112 113 #captcha-input:hover, 114 #captcha-input:focus { 115 border-color: var(--color-primary); 116 } 117 118 .result-wrapper { 119 padding: 8px; 120 display: none; 121 justify-content: center; 122 align-items: center; 123 margin-top: 12px; 124 width: 240px; 125 height: 80px; 126 border-radius: var(--radis-base); 127 background-color: var(--color-danger); 128 transition: background 0.3s ease-in; 129 color: #fff; 130 font-size: 14px; 131 word-break: break-all; 132 } 133 </style> 134 </head> 135 <body> 136 <div class="main"> 137 <h1 class="title">请完成安全验证/Security verification required</h1> 138 139 <div class="img-wrapper"> 140 <img id="captcha-img" width="200" height="120" /> 141 142 <button class="button info" onclick="refreshCaptcha()">刷新/Refresh</button> 143 </div> 144 145 <div class="input-wrapper"> 146 <input id="captcha-input" type="text" /> 147 148 <button class="button primary" type="submit" onclick="verifyCaptcha()"> 149 提交/Submit 150 </button> 151 </div> 152 153 <div class="result-wrapper"></div> 154 </div> 155 156 <script> 157 const img = document.getElementById("captcha-img"); 158 const input = document.getElementById("captcha-input"); 159 const resultEl = document.getElementsByClassName("result-wrapper")[0]; 160 161 let verifying = false; 162 163 // 刷新验证码 164 function refreshCaptcha() { 165 // 添加随机参数防止缓存 166 img.src = "/captcha-image?" + Math.random().toString() + Date.now(); 167 } 168 169 function onError(message) { 170 resultEl.style.display = "flex"; 171 resultEl.textContent = message; 172 } 173 174 // 验证验证码 175 async function verifyCaptcha() { 176 if (verifying) { 177 return false; 178 } 179 180 const code = input.value; 181 182 if (!code || code.trim() === "") { 183 return onError("输入不能为空/Input cannot be empty"); 184 } 185 186 verifying = true; 187 188 try { 189 const res = await fetch([`/captcha-verify?code=${code}`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.code.md)); 190 const data = await res.json(); 191 192 if (data.status === "success") { 193 window.location.href = "/"; 194 } else { 195 onError(data.msg); 196 refreshCaptcha(); // 验证失败自动刷新 197 input.focus(); 198 } 199 } catch (err) { 200 onError("验证失败, 请稍后重试/Verification failed, please try again later"); 201 } finally { 202 verifying = false; 203 } 204 } 205 206 // 初始化验证码 207 window.onload = refreshCaptcha; 208 input.addEventListener("keydown", (e) => { 209 if (e.key.toLowerCase() === "enter") { 210 verifyCaptcha(); 211 } 212 }); 213 </script> 214 </body> 215</html> 216
效果显著,在删除 100 多个网段封禁后,在极短的时间内控制住了流量访问,外网出带宽使用率在初期出现了涨幅,十几分钟后趋于平稳。
--end
《前端仔的一次运维实践》 是转载文章,点击查看原文。
