本文介绍一套可直接落地的 1Panel + 雷池(SafeLine)T1K 旁路接入方案。1Panel OpenResty 继续负责域名、证书和反向代理,雷池 Detector 在本机提供检测服务,OpenResty 通过官方 Lua SDK 在请求进入后端前完成检测和拦截。
请求路径:客户端 → 1Panel OpenResty → T1K Lua SDK → SafeLine Detector(127.0.0.1:8000)→ 后端应用。
整套接入只需要维护一个目录:/opt/1panel/www/common/safeline-t1k。不改站点的 upstream,不改变证书签发方式,也不要求重新编译 OpenResty。
一、部署完成后的组件关系
公网 80/443
│
▼
1Panel OpenResty
├─ access 阶段:调用 /www/common/safeline-t1k/hook.lua
├─ header_filter 阶段:同一个 hook.lua 处理响应
└─ 放行后:继续执行 1Panel 原生 WAF(由后台开关决定)
│
▼
SafeLine Detector 127.0.0.1:8000
│
├─ action ".":放行
└─ 其他 action:拦截并返回安全响应
│
▼
站点后端(例如 127.0.0.1:3030)SafeLine 管理面板和官方 Compose 服务按官方方式运行;网站流量由 1Panel OpenResty 直接承载,Detector 只接受本机回环连接。
二、前置条件
- 已安装 Docker、Docker Compose 和 1Panel。
- 1Panel OpenResty 已创建至少一个可正常访问的站点。
- 已准备 SafeLine 官方 Compose 文件和管理账号。
- 有权限修改 SafeLine Compose、Detector 配置和 OpenResty 的
waf.conf。 - 准备一个备份目录,保存修改前的配置和 Lua 文件。
本文示例路径以 1Panel 默认目录为准。如果安装路径不同,只需将命令中的路径替换为实际路径。
三、部署 SafeLine 官方 Compose
请以 SafeLine 官方部署文档中的 Compose 模板为准。镜像标签使用 latest:
cd /opt/1panel/docker/compose/safeline
# .env
IMAGE_TAG=latest
docker compose pull
docker compose up -d
docker compose ps所有 SafeLine 服务应处于运行状态。先在 SafeLine 控制台完成初始化和必要的站点/策略配置,再继续接入 OpenResty。
四、配置 Detector 本机 TCP 服务
4.1 修改 detector.yml
打开 SafeLine 数据目录中的 resources/detector/detector.yml,确认监听配置如下:
bind_addr: 0.0.0.0
listen_port: 80000.0.0.0 表示 Detector 在容器内监听所有接口;宿主机是否对外暴露由 Compose 的端口映射决定。
4.2 修改 detect 服务端口映射
在官方 Compose 的 detect 服务中加入:
ports:
- "127.0.0.1:8000:8000"这样宿主机只有回环地址可以访问 Detector,外部网络无法直接连接 8000 端口。应用配置:
docker compose config -q
docker compose up -d --force-recreate detect
docker compose ps detect
ss -lntp | grep 127.0.0.1:8000确认容器为 healthy,并且宿主机显示 127.0.0.1:8000 监听。不要使用不带地址的 8000:8000 映射。
五、安装官方 lua-resty-t1k SDK
SDK 来自官方 lua-resty-t1k 项目。将其中的 resty/t1k.lua 和 resty/t1k/ 子目录复制到:
/opt/1panel/www/common/safeline-t1k/vendor/resty/最终目录应类似:
/opt/1panel/www/common/safeline-t1k/
├── hook.lua
├── manage.sh
└── vendor/
└── resty/
├── t1k.lua
└── t1k/
├── checker.lua
├── handler.lua
├── http.lua
└── ...保持 SDK 的目录层级不变。hook.lua 会把 /www/common/safeline-t1k/vendor 加入 Lua 的 package.path,因此 SDK 不需要安装到 OpenResty 的系统 Lua 目录。
六、部署单一 hook.lua
将下面的脚本保存为 /opt/1panel/www/common/safeline-t1k/hook.lua。它同时用于 access 和 header_filter 两个阶段:
- 连接
127.0.0.1:8000,使用block模式。 - 只有 action
.放行;其他动作统一拦截,兼容 Detector 返回的新动作。 Accept: text/html返回浏览器拦截页,API 请求返回 JSON。- Detector 连接失败时记录日志并放行,保证安全组件故障不会直接拖垮站点。
- 放行后加载 1Panel 原生
waf.lua,保留后台开关。
展开查看完整 hook.lua
-- SafeLine T1K single-phase hook for 1Panel OpenResty.
-- The detector can return verdicts added after the Lua SDK release.
-- Any non-pass verdict is handled as a block; transport errors remain fail-open
-- and are logged so the OpenResty service stays available during detector outages.
local sdk_root = "/www/common/safeline-t1k/vendor"
if not package.path:find(sdk_root, 1, true) then
package.path = sdk_root .. "/?.lua;" .. sdk_root .. "/?/init.lua;" .. package.path
end
local phase = ngx.get_phase()
if phase == "header_filter" then
if ngx.ctx.safeline_t1k_enabled then
local t1k = require "resty.t1k"
local ok, err = pcall(t1k.do_header_filter)
if not ok then
ngx.log(ngx.ERR, "SafeLine T1K header filter error: ", err)
end
end
return
end
local uri = ngx.var.uri or ""
local remote_addr = ngx.var.remote_addr or ""
local loopback = remote_addr == "127.0.0.1" or remote_addr == "::1"
local bypass = uri:find("^/%.well%-known/acme%-challenge/") ~= nil
or uri:find("^/%.well%-known/pki%-validation/") ~= nil
or (loopback and (
uri == "/nginx_status"
or uri == "/log_maintenance"
or uri == "/get_site_stat"
))
if not bypass then
local t1k = require "resty.t1k"
local options = {
mode = "block",
host = "127.0.0.1",
port = 8000,
connect_timeout = 100,
send_timeout = 300,
read_timeout = 1000,
req_body_size = 1024,
keepalive_size = 256,
keepalive_timeout = 60000,
}
-- Do not ask the SDK handler to interpret the action. The installed
-- SDK only knows "." and "?", while newer detectors may return another
-- non-pass verdict such as "~".
local ok, err, result = t1k.do_access(options, false)
if not ok then
ngx.log(ngx.ERR, "SafeLine T1K access error: ", err or "unknown error")
elseif result and result.action == "." then
ngx.ctx.safeline_t1k_enabled = true
elseif result then
local status = tonumber(result.status) or ngx.HTTP_FORBIDDEN
if status < 400 or status > 599 then
status = ngx.HTTP_FORBIDDEN
end
if result.action ~= "?" then
ngx.log(ngx.WARN, "SafeLine T1K handled non-pass action: ",
tostring(result.action), " status=", tostring(status),
" event_id=", tostring(result.event_id or ""))
end
ngx.status = status
ngx.header["Cache-Control"] = "no-store"
ngx.header["X-Content-Type-Options"] = "nosniff"
local event_id = tostring(result.event_id or "")
-- Event IDs are normally alphanumeric; escape defensively for HTML output.
local html_event_id = event_id
:gsub("&", "&")
:gsub("<", "<")
:gsub(">", ">")
:gsub('"', """)
local accept = ngx.var.http_accept or ""
if accept:find("text/html", 1, true) then
ngx.header.content_type = "text/html; charset=utf-8"
ngx.header["Content-Security-Policy"] =
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'"
ngx.say(string.format([[
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>请求已被拦截</title>
<style>
:root { color-scheme: light; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; background: #f4f7fb; color: #1f2937; }
.card { width: min(560px,100%%); padding: 40px 36px; background: #fff; border: 1px solid #e5e7eb; border-radius: 18px; box-shadow: 0 14px 38px rgba(15,23,42,.08); text-align: center; }
.icon { width: 64px; height: 64px; margin: 0 auto 20px; border-radius: 50%%; display: grid; place-items: center; background: #fee2e2; color: #b91c1c; font-size: 32px; font-weight: 700; }
h1 { margin: 0 0 12px; font-size: 26px; letter-spacing: .01em; }
p { margin: 10px 0; color: #64748b; line-height: 1.75; }
.status { display: inline-block; margin: 18px 0 12px; padding: 6px 12px; border-radius: 999px; background: #fff1f2; color: #be123c; font-size: 13px; font-weight: 600; }
.event { margin-top: 22px; padding: 12px 14px; border-radius: 10px; background: #f8fafc; color: #475569; font: 13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace; word-break: break-all; }
.help { margin-top: 22px; font-size: 13px; color: #94a3b8; }
</style>
</head>
<body>
<main class="card" role="main">
<div class="icon" aria-hidden="true">!</div>
<h1>请求已被拦截</h1>
<div class="status">HTTP %d · SafeLine WAF</div>
<p>该请求被网站安全策略拦截</p>
<div class="event">事件编号:%s</div>
<div class="help">Chaitin SafeLine Web Application Firewall</div>
</main>
</body>
</html>
]], status, html_event_id))
else
ngx.header.content_type = "application/json"
ngx.say(string.format(
'{"code": %d, "success":false, "message":"blocked by Chaitin SafeLine Web Application Firewall", "event_id":"%s"}',
status, event_id))
end
return ngx.exit(status)
else
ngx.log(ngx.ERR, "SafeLine T1K returned no verdict")
end
end
local cache_key = "__safeline_panel_waf_loader"
local panel_waf = package.loaded[cache_key]
if panel_waf == nil then
local loader, err = loadfile("/usr/local/openresty/1pwaf/waf.lua")
if not loader then
ngx.log(ngx.ERR, "1Panel WAF loader error: ", err)
return
end
package.loaded[cache_key] = loader
panel_waf = loader
end
return panel_waf()
七、接入 1Panel OpenResty
编辑 OpenResty 应用中的 1pwaf/data/conf/waf.conf。保留文件内已有的 init、log、worker 等指令,将 access 和 header_filter 两行设置为:
access_by_lua_file /www/common/safeline-t1k/hook.lua;
header_filter_by_lua_file /www/common/safeline-t1k/hook.lua;不要删除其他站点和 WAF 配置。保存后执行语法检查并平滑重载:
docker exec 1Panel-openresty openresty -t
docker exec 1Panel-openresty openresty -s reload容器名称可在 1Panel“容器”页面查看并替换。也可以使用 1Panel OpenResty 应用页面的“重载”按钮。
八、使用 manage.sh 统一管理
manage.sh 是本方案的日常管理入口,负责检查依赖、写入 OpenResty 两阶段 hook、创建配置备份以及平滑重载。将脚本保存为 /opt/1panel/www/common/safeline-t1k/manage.sh,并赋予执行权限。
8.1 完整脚本
下面内容与当前部署脚本一致。默认路径适用于 1Panel 标准目录;CONTAINER 必须改成实际的 OpenResty 容器名称。可用 docker ps --format '{{.Names}}' 查看容器名。
#!/bin/sh
set -eu
BASE=/opt/1panel/www/common/safeline-t1k
WAF=/opt/1panel/apps/openresty/openresty/1pwaf/data/conf/waf.conf
OPENRESTY_DIR=/opt/1panel/apps/openresty/openresty
CONTAINER=1Panel-openresty-5jNp
die() {
printf '%s\n' "SafeLine T1K: $*" >&2
exit 1
}
backup_waf() {
stamp=$(date +%Y%m%d-%H%M%S)
dir=/opt/1panel/backup/safeline-t1k-manage-$stamp
mkdir -p "$dir"
cp --preserve=mode,timestamps "$WAF" "$dir/waf.conf"
printf '%s\n' "$dir"
}
check() {
test -f "$BASE/hook.lua" || die "hook.lua missing"
test -f "$BASE/vendor/resty/t1k.lua" || die "vendored T1K SDK missing"
test -f /opt/1panel/docker/compose/safeline/data/resources/detector/detector.yml || die "detector.yml missing"
grep -Fq 'bind_addr:' /opt/1panel/docker/compose/safeline/data/resources/detector/detector.yml || die "Detector TCP bind is not configured"
grep -Fq 'listen_port: 8000' /opt/1panel/docker/compose/safeline/data/resources/detector/detector.yml || die "Detector TCP port is not configured"
grep -Fq 'access_by_lua_file /www/common/safeline-t1k/hook.lua;' "$WAF" || die "access hook is not enabled"
grep -Fq 'header_filter_by_lua_file /www/common/safeline-t1k/hook.lua;' "$WAF" || die "header filter hook is not enabled"
cd "$OPENRESTY_DIR"
docker compose config -q
docker exec "$CONTAINER" openresty -t
docker exec "$CONTAINER" resty -e 'local s=ngx.socket.tcp(); s:settimeout(1000); local ok,err=s:connect("127.0.0.1",8000); if not ok then error(err) end; s:close(); print("SafeLine T1K TCP reachable")'
printf '%s\n' 'SafeLine T1K simplified integration checks passed'
}
enable() {
backup=$(backup_waf)
sed -i 's|access_by_lua_file /usr/local/openresty/1pwaf/waf.lua;|access_by_lua_file /www/common/safeline-t1k/hook.lua;|' "$WAF"
sed -i 's|access_by_lua_file /www/common/safeline-t1k/access.lua;|access_by_lua_file /www/common/safeline-t1k/hook.lua;|' "$WAF"
sed -i '\|header_filter_by_lua_file /www/common/safeline-t1k/header_filter.lua;|d' "$WAF"
if ! grep -Fq 'header_filter_by_lua_file /www/common/safeline-t1k/hook.lua;' "$WAF"; then
sed -i '/access_by_lua_file \/www\/common\/safeline-t1k\/hook.lua;/a\header_filter_by_lua_file /www/common/safeline-t1k/hook.lua;' "$WAF"
fi
docker exec "$CONTAINER" openresty -t
docker exec "$CONTAINER" openresty -s reload
printf '%s\n' "SafeLine T1K enabled. Backup: $backup"
}
disable() {
backup=$(backup_waf)
sed -i 's|access_by_lua_file /www/common/safeline-t1k/hook.lua;|access_by_lua_file /usr/local/openresty/1pwaf/waf.lua;|' "$WAF"
sed -i '\|header_filter_by_lua_file /www/common/safeline-t1k/hook.lua;|d' "$WAF"
docker exec "$CONTAINER" openresty -t
docker exec "$CONTAINER" openresty -s reload
printf '%s\n' "SafeLine T1K disabled; original 1Panel WAF hook restored. Backup: $backup"
}
case "${1:-check}" in
check) check ;;
enable) enable ;;
disable) disable ;;
*) die "usage: $0 {check|enable|disable}" ;;
esac
8.2 脚本做了什么
| 子命令 | 执行内容 | 适用时机 |
|---|---|---|
check | 检查 hook、vendor SDK、Detector 配置、waf.conf 两行指令、Compose、OpenResty 语法以及 127.0.0.1:8000 连通性。 | 首次部署、升级后、故障排查 |
enable | 先备份 waf.conf,再将 access/header_filter 指向 hook.lua,执行语法检查并 reload。 | 首次接入或面板覆盖配置后 |
disable | 先备份 waf.conf,恢复 1Panel 原生 access hook,移除 T1K header_filter 指令并 reload。 | 临时停用旁路或定位问题 |
8.3 执行命令
chmod 755 /opt/1panel/www/common/safeline-t1k/manage.sh
# 查看当前 OpenResty 容器名称
docker ps --format '{{.Names}}'
# 全量检查
/opt/1panel/www/common/safeline-t1k/manage.sh check
# 启用 T1K 旁路
/opt/1panel/www/common/safeline-t1k/manage.sh enable
# 停用 T1K 旁路(保留 Detector、SDK 和脚本)
/opt/1panel/www/common/safeline-t1k/manage.sh disable脚本使用 set -eu,任何检查失败都会立即停止,不会继续 reload 一个不完整的配置。每次修改 waf.conf 前都会在 /opt/1panel/backup/ 下创建带时间戳的备份;启用或停用后都建议再次执行 check。
九、验证部署是否成功
9.1 服务和配置检查
docker compose ps
docker compose logs --tail=100 detect
docker exec 1Panel-openresty openresty -t
curl -I https://example.com/正常访问应返回后端的正常状态码。OpenResty error.log 中不应持续出现 Detector 连接失败。
9.2 验证拦截响应
请只在自己拥有或明确获授权的测试站点上验证。使用会触发你已启用规则的测试请求,并分别测试浏览器和 API:
# 浏览器请求:返回 HTML 拦截页
curl -i -H 'Accept: text/html' 'https://example.com/your-test-path'
# API 请求:返回 JSON
curl -i -H 'Accept: application/json' 'https://example.com/your-test-path'拦截响应通常为 403 或 Detector 指定的 4xx/5xx 状态码,并包含 Cache-Control: no-store。同时可在 SafeLine 控制台查看对应安全事件。
十、1Panel WAF 开关
| 后台设置 | 请求链路 | 结果 |
|---|---|---|
| 关闭 1Panel WAF | T1K → 后端 | 只由雷池检测和拦截 |
| 开启 1Panel WAF | T1K 放行 → 1Panel WAF → 后端 | 叠加 1Panel 的规则和限速 |
如果不希望 1Panel WAF 参与拦截,在后台关闭即可,不需要改动 hook.lua 或站点反代配置。雷池旁路仍然独立工作。
十一、修改 HTML 拦截页
HTML 模板位于 hook.lua 的内联字符串中。OpenResty 默认启用 lua_code_cache on,所以编辑文件后必须重载 worker:
docker exec 1Panel-openresty openresty -t
docker exec 1Panel-openresty openresty -s reload也可以点击 1Panel OpenResty 应用页面的“重载”。生产环境不建议关闭 Lua 代码缓存;平滑重载不会中断现有连接。
十二、升级和恢复
12.1 SafeLine 升级
- 备份 SafeLine Compose、
detector.yml和safeline-t1k目录。 - 保持
IMAGE_TAG=latest,执行docker compose pull和docker compose up -d。 - 确认 Detector healthy、回环端口监听正常,再运行
manage.sh check。 - 完成一次正常请求和一次授权拦截测试。
12.2 OpenResty 升级
升级后检查 /www/common/safeline-t1k 是否仍被挂载、waf.conf 两行是否仍指向 hook.lua,然后执行:
/opt/1panel/www/common/safeline-t1k/manage.sh check如果面板更新覆盖了 waf.conf,执行 manage.sh enable 重新写入配置并重载。
12.3 恢复服务
出现异常时先执行:
/opt/1panel/www/common/safeline-t1k/manage.sh disable确认 OpenResty 恢复正常后,再根据备份逐项检查 Detector、SDK 和 hook 配置。恢复文件后务必执行 openresty -t 再 reload。
十三、故障排查
| 现象 | 检查项 |
|---|---|
| Detector connection refused | 检查 detector.yml、127.0.0.1:8000:8000 映射、容器健康状态和端口监听。 |
返回 action ~ 等非放行动作 | 确认 hook 使用“只有 . 放行”的判断逻辑,并在 SafeLine 事件中核对规则结果。 |
| 浏览器看到 JSON | 请求是否带有 Accept: text/html;waf.conf 是否指向当前 hook;修改后是否 reload。 |
| 修改模板后仍显示旧页面 | 执行 openresty -t 和平滑 reload,确认容器内文件已更新。 |
| 正常请求被 403 | 分别查看 SafeLine 事件、1Panel WAF 开关和 OpenResty error.log;可临时执行 manage.sh disable 定位层次。 |
| OpenResty 语法检查失败 | 检查 Lua 文件路径、SDK 目录层级、waf.conf 分号和容器内挂载路径。 |
十四、上线检查清单
- SafeLine 官方 Compose 已启动,镜像标签为
latest。 - Detector 容器 healthy,宿主机只监听
127.0.0.1:8000。 vendor/resty/t1k.lua和vendor/resty/t1k/完整可读。waf.conf的 access/header_filter 都指向同一个hook.lua。- 1Panel WAF 已按预期关闭或开启。
- 正常请求、授权拦截请求和 HTML/JSON 两种响应均已验证。
- 已测试
manage.sh disable的恢复路径并保留备份。
完成以上步骤后,1Panel 负责站点入口和运维,雷池负责独立安全检测,二者通过本机 TCP Detector 连接,配置边界清晰,日常维护集中在一个目录内。