diff --git a/README.md b/README.md index 50df685..ad1d21c 100755 --- a/README.md +++ b/README.md @@ -121,10 +121,12 @@ 根目录提供一键安装脚本,按依赖顺序依次构建并安装各模块(库先行,core 最后): ```bash -# 1. 安装系统依赖(Debian/Ubuntu) -bash _setup.deb.sh +# 1. 安装系统依赖 +bash _setup.deb.sh # Debian/Ubuntu +# 或 +bash _setup.rhel.sh # RHEL/CentOS/Rocky/Fedora -# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> static -> dnfs -> db -> core) +# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> core) bash _inst.sh ``` diff --git a/_inst.sh b/_inst.sh index d350e75..a701068 100755 --- a/_inst.sh +++ b/_inst.sh @@ -30,6 +30,7 @@ inst_module "libhttp" inst_module "libcrypt" inst_module "libsqlite3" inst_module "gateway" +inst_module "waf" # waf 在 gateway 之前编译,部署时由 core 先拉起 waf 再拉起 gateway inst_module "static" inst_module "dnfs" inst_module "db" diff --git a/_setup.deb.sh b/_setup.deb.sh index a201eab..42ad15e 100755 --- a/_setup.deb.sh +++ b/_setup.deb.sh @@ -2,4 +2,4 @@ # Debian sudo apt update -sudo apt install libsqlite3-dev libboost-all-dev libcurl4-gnutls-dev python3 python3-venv python3-pip g++ patchelf +sudo apt install libsqlite3-dev libboost-all-dev libcurl4-gnutls-dev python3 python3-venv python3-pip g++ patchelf golang-go diff --git a/_setup.rhel.sh b/_setup.rhel.sh new file mode 100644 index 0000000..2edfb04 --- /dev/null +++ b/_setup.rhel.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# RHEL / CentOS / Rocky / Fedora +sudo dnf install -y boost-devel libcurl-devel python3 python3-pip gcc-c++ patchelf golang sqlite-devel diff --git a/_setup.sh b/_setup.sh deleted file mode 100644 index c3011fe..0000000 --- a/_setup.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -# Debian -sudo apt update -sudo apt install libboost-all-dev libcurl4-gnutls-dev python3 python3-venv python3-pip g++ patchelf diff --git a/captcha/README.md b/captcha/README.md new file mode 100644 index 0000000..5527ba1 --- /dev/null +++ b/captcha/README.md @@ -0,0 +1,136 @@ +# CAPTCHA 验证码服务 + +一个轻量级的验证码图片服务,仅依赖 Python 标准库实现 HTTP 服务(无需 Flask),配合 Pillow 生成验证码图片。提供图片生成、Token 校验、健康检查等接口,适合作为后端服务的验证码组件。 + +## 功能特性 + +- **图片验证码生成**:5 位字符(字母 + 数字),自动排除易混淆字符(`l I 1 O 0 o`) +- **Token 机制**:每次生成图片返回唯一 Token,校验时一次性消费,防止重放 +- **过期自动清理**:Token 默认 300 秒(5 分钟)失效,每次请求时顺带清理过期项 +- **线程安全**:使用锁保护内存存储,支持并发请求 +- **恒定时间比较**:答案校验使用逐字符异或比较,避免计时攻击 +- **干扰元素**:随机字符颜色、位置偏移、干扰线与噪点,提升识别难度 +- **零框架依赖**:HTTP 层基于 `http.server`,除 Pillow 外无第三方依赖 + +## 技术栈 + +- Python 3 +- [Pillow](https://python-pillow.org/)(PIL)—— 图片绘制 +- Python 标准库:`http.server`、`threading`、`secrets`、`json`、`urllib` + +## 安装 + +```bash +# 安装图片处理依赖 +pip install Pillow +``` + +> 字体默认使用 `/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf`(Linux 常见路径)。若系统中不存在该字体,会自动回退到 PIL 默认字体。 + +## 运行 + +```bash +# 方式一:通过启动脚本 +./_run.sh + +# 方式二:直接运行 +python3 captcha_service.py +``` + +服务启动后监听 `http://127.0.0.1:8009`。 + +## 配置项 + +在 `captcha_service.py` 顶部可调整以下常量: + +| 常量 | 默认值 | 说明 | +| ------------- | ------------ | --------------------- | +| `CAPTCHA_LEN` | `5` | 验证码字符长度 | +| `CAPTCHA_TTL` | `300` | Token 有效期(秒) | +| `ADDR` | `127.0.0.1` | 监听地址 | +| `PORT` | `8009` | 监听端口 | + +## API 接口 + +### 1. 获取验证码图片 + +``` +GET /captcha/image +``` + +返回一张 PNG 验证码图片,响应头 `X-Captcha-Token` 携带本次验证的 Token,需在后续校验时一并提供。 + +**示例:** + +```bash +curl -D - http://127.0.0.1:8009/captcha/image -o captcha.png +``` + +响应头示例: + +``` +Content-Type: image/png +X-Captcha-Token: 3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c +``` + +### 2. 校验验证码 + +``` +POST /captcha/verify +Content-Type: application/json +``` + +请求体: + +```json +{ + "token": "3f9a1c2b4d5e6f7a8b9c0d1e2f3a4b5c", + "answer": "AB3XY" +} +``` + +响应(校验不区分大小写,Token 校验后即失效): + +```json +{ "valid": true } +``` + +错误情况: + +| HTTP 状态码 | 响应体 | 说明 | +| ----------- | --------------------------------------------------- | ----------------------- | +| `400` | `{ "valid": false, "error": "invalid json" }` | 请求体非合法 JSON | +| `400` | `{ "valid": false, "error": "token and answer required" }` | 缺少 token 或 answer | +| `400` | `{ "valid": false, "error": "invalid or expired token" }` | Token 无效或已过期 | + +### 3. 健康检查 + +``` +GET /health +``` + +```json +{ "status": "ok", "service": "captcha" } +``` + +## 典型使用流程 + +1. 客户端调用 `GET /captcha/image`,保存返回的图片与响应头中的 `X-Captcha-Token`。 +2. 用户根据图片内容输入验证码文本。 +3. 客户端调用 `POST /captcha/verify`,提交 `token` 与用户输入的 `answer`。 +4. 服务端比对答案(小写化、恒定时间比较),返回 `valid` 结果,Token 随即作废。 + +## 项目结构 + +``` +captcha/ +├── captcha_service.py # 服务主程序(HTTP 服务 + 验证码生成与校验) +├── _run.sh # 启动脚本 +└── README.md +``` + +## 注意事项 + +- 验证码存储在进程内存中,服务重启后所有 Token 失效,不适合多实例水平扩展场景(如需分布式可替换为 Redis 等外部存储)。 +- 默认仅监听 `127.0.0.1`,仅供本机或内网调用;如需对外暴露请配合反向代理并做好限流。 +- 答案校验不区分大小写,Token 一经校验即从内存中移除(无论对错)。 diff --git a/core/init.txt b/core/init.txt index 69e30d0..7c0c155 100644 --- a/core/init.txt +++ b/core/init.txt @@ -2,5 +2,6 @@ bash /opt/micro_servers/ai_review/_run.sh bash /opt/micro_servers/db/_run.sh bash /opt/micro_servers/dnfs/_run.sh bash /opt/micro_servers/static/_run.sh +bash /opt/micro_servers/waf/_run.sh bash /opt/micro_servers/gateway/_run.sh bash /opt/micro_servers/captcha/_run.sh diff --git a/gateway/example.ini b/gateway/example.ini index 697ce1a..617b5f6 100644 --- a/gateway/example.ini +++ b/gateway/example.ini @@ -1,6 +1,10 @@ [Server] -address = 0.0.0.0 -port = 443 +# 当 WAF 在前面终止 TLS 时,gateway 退化为内部 HTTP 服务: +# - tls = off 启用 HTTP 模式(配合 waf 模块使用) +# - tls = on 启用 HTTPS 模式(独立部署,证书为 full_chain.crt / server.key) +address = 127.0.0.1 +port = 8443 +tls = off [Proxy] default = http://127.0.0.1:9200 diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 45c91a6..7a90b26 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -280,6 +280,7 @@ bool is_special_header(const std::string &header); /* configure items */ static std::string _ADDR; static int _PORT; +static bool _TLS = false; static std::string _DEFT_URL; static std::vector _OPEN_URL; static std::string _DB_PATH; @@ -383,11 +384,12 @@ int main(int argc, char **argv) { try { ConfIni cf(vm["input"].as()); - _ADDR = cf.get("Server.address", ""); - _PORT = cf.get ("Server.port", -1); - _DEFT_URL = cf.get("Proxy.default", ""); - _OPEN_URL = split(cf.get("Proxy.opened", ""), ';'); - _DB_PATH = cf.get("Database.path", ""); + _ADDR = cf.get("Server.address", ""); + _PORT = cf.get ("Server.port", -1); + _TLS = cf.get ("Server.tls", false); + _DEFT_URL = cf.get("Proxy.default", ""); + _OPEN_URL = split(cf.get("Proxy.opened", ""), ';'); + _DB_PATH = cf.get("Database.path", ""); } catch (ConfIni::IniConfigureFileParseException &e) { std::cerr << "ERROR: " << e.what() << std::endl; @@ -402,7 +404,13 @@ int main(int argc, char **argv) { return 0; } - _SERVE = std::make_unique (_ADDR, _PORT, "full_chain.crt", "server.key", 4); + // 根据 Server.tls 选择 HTTPS(带证书)或 HTTP(无证书)构造函数 + // HTTP 模式用于 WAF 终止 TLS 后的内部转发链路 + if (_TLS) { + _SERVE = std::make_unique (_ADDR, _PORT, "full_chain.crt", "server.key", 4); + } else { + _SERVE = std::make_unique (_ADDR, _PORT, 4); + } _KEYGEN = std::make_unique (); _SQLDB = std::make_unique (_DB_PATH); diff --git a/waf/.gitignore b/waf/.gitignore new file mode 100644 index 0000000..43fdf7f --- /dev/null +++ b/waf/.gitignore @@ -0,0 +1,15 @@ +# 编译产物 +waf.bin +*.exe + +# 证书与密钥(不入库) +certs/ + +# OWASP CRS(按需 clone,不入库) +rules/crs/ + +# 本地覆盖配置 +config.local.yaml + +# Go +vendor/ diff --git a/waf/README.md b/waf/README.md new file mode 100644 index 0000000..0e42b95 --- /dev/null +++ b/waf/README.md @@ -0,0 +1,172 @@ +# waf — Web 应用防火墙网关 + +基于 Go + [coraza](https://github.com/corazawaf/coraza) 的 WAF 网关,部署在 `gateway` 之前,终止 TLS 并按 SecLang 规则库匹配请求,未命中则转发到 gateway。 + +``` +客户端 ──HTTPS──▶ waf:443 (终止 TLS + coraza 规则匹配) ──明文 HTTP──▶ gateway:8443 (鉴权 + 反代) +``` + +## 为什么是 Go + +- `coraza-waf/coraza` 是 ModSecurity SecLang 的 Go 实现,100% 兼容 OWASP CRS v4 +- `net/http/httputil.ReverseProxy` 标准库反向代理,零第三方依赖 +- goroutine + net/http 吞吐量比 Python 高一个数量级 +- `go build` 出单二进制,`core/init.txt` 直接拉起 + +## 极客友好的规则系统 + +| 操作 | 命令 | +|------|------| +| 编辑规则 | `vim rules/local/custom.conf` | +| 校验语法 | `./waf.bin -test rules/local/custom.conf` | +| 列出所有规则 | `./waf.bin -list` | +| 测试输入 | `./waf.bin -check "/?id=1' OR '1'='1"` | +| 热加载(不重启) | `kill -HUP $(pgrep waf)` | +| 临时禁用某文件 | `mv rules/local/x.conf x.conf.bak && kill -HUP $(pgrep waf)` | + +### 规则文件组织 + +``` +rules/ +├── waf.conf # 主入口,Include 各子文件 +├── local/ # 本地自定义规则(ID 段 100000-199999) +│ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA +│ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById) +└── crs/ # OWASP CRS(git clone,不入库,ID 段 900000-999999) +``` + +### 规则 ID 分段 + +- `100000-199999` 本地自定义 +- `900000-999999` OWASP CRS + +### 规则语法(SecLang) + +```apache +SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \ + "id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'" +``` + +字段说明: +- `REQUEST_URI|ARGS` 匹配目标(URI / 参数 / Header / Cookie 等) +- `@rx ...` 正则匹配(也支持 `@pm`、`@contains`、`@beginsWith` 等) +- `phase:1` 请求头阶段 / `phase:2` 请求体阶段 / `phase:3` 响应头 / `phase:4` 响应体 +- `deny,status:403` 命中后动作 +- `msg` 命中日志描述 + +## 部署形态 + +WAF 必须终止 TLS 才能看到明文 body,因此证书需从 gateway 迁到 WAF: + +1. 把 `gateway` 的 `full_chain.crt` / `server.key` 复制到 `waf/certs/` +2. `gateway` 改为监听 `127.0.0.1:8443`,关闭 TLS(仅本机) +3. `waf` 监听 `:443`,转发到 `http://127.0.0.1:8443` + +## 配置 + +`config.yaml`: + +```yaml +listen: ":443" +tls_cert: "./certs/full_chain.crt" +tls_key: "./certs/server.key" +upstream: "http://127.0.0.1:8443" +rules_dir: "./rules" +``` + +## 目录结构 + +``` +waf/ +├── main.go # 入口:服务模式 + CLI 子命令 +├── config.yaml # 配置 +├── go.mod +├── internal/ +│ ├── engine/ +│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测 +│ └── proxy/ +│ └── proxy.go # ReverseProxy + WAF 事务接入 +├── rules/ +│ ├── waf.conf +│ └── local/ +│ ├── custom.conf +│ └── exceptions.conf +├── _set.sh # 编译 +├── _run.sh # 运行 +├── .gitignore +└── README.md +``` + +## 安装 + +```bash +# 1. 安装 Go 1.21+ +brew install go # macOS +# 或 sudo apt install golang-go # Debian/Ubuntu + +# 2. 编译 +bash _set.sh + +# 3. 准备证书 +mkdir certs && cp /path/to/full_chain.crt /path/to/server.key certs/ + +# 4. 运行 +bash _run.sh +``` + +## 接入 core 自启动 + +在 `core/init.txt` 增加: + +``` +bash /opt/micro_servers/waf/_run.sh +``` + +并把 `gateway` 改为内部 HTTP 端口(不再监听 443)。 + +## 命中日志 + +命中规则时输出 JSON 一行,便于 `jq` 分析: + +```json +{"ts":"2026-07-25T10:00:00Z","action":"block","rule_id":100001,"ip":"1.2.3.4","method":"GET","path":"/","query":"id=1'+OR+'1'='1","ua":"curl/8.0","cost_ms":0} +``` + +聚合统计: + +```bash +journalctl -u Nidus -o cat | grep '"action":"block"' | \ + jq -r '.rule_id' | sort | uniq -c | sort -rn +``` + +## 启用 OWASP CRS(可选) + +```bash +cd rules +git clone --depth 1 https://github.com/coreruleset/coreruleset.git crs +``` + +在 `rules/waf.conf` 取消注释: + +```apache +Include crs/coraza.conf +Include crs/rules/*.conf +``` + +然后 `kill -HUP $(pgrep waf)`。 + +## 已知限制 + +- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。 +- 响应体检测未启用(需缓冲整个响应,延迟成本高)。 +- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。 + +## 依赖 + +- Go 1.21+ +- github.com/corazawaf/coraza/v3 v3.7.0 +- gopkg.in/yaml.v3 + +## 许可证 + +MIT License diff --git a/waf/_run.sh b/waf/_run.sh new file mode 100644 index 0000000..260fb9f --- /dev/null +++ b/waf/_run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# 运行 WAF(需先 _set.sh 编译) +cd "$(dirname "$0")" || exit +exec ./waf.bin -c config.yaml diff --git a/waf/_set.sh b/waf/_set.sh new file mode 100644 index 0000000..7402bcf --- /dev/null +++ b/waf/_set.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# 构建 WAF 单二进制 +set -e +cd "$(dirname "$0")" || exit + +if ! command -v go >/dev/null 2>&1; then + echo "[ERROR] 未找到 go,请先安装 Go 1.21+" + exit 1 +fi + +echo "[1/2] 下载依赖" +go mod download + +echo "[2/2] 编译" +CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o waf.bin . + +echo "完成: $(pwd)/waf.bin" diff --git a/waf/config.yaml b/waf/config.yaml new file mode 100644 index 0000000..87fd606 --- /dev/null +++ b/waf/config.yaml @@ -0,0 +1,10 @@ +# WAF 配置文件 +listen: ":443" +tls_cert: "./certs/full_chain.crt" +tls_key: "./certs/server.key" + +# gateway 内部 HTTP 端口(gateway 需改为监听 127.0.0.1:8443 不启用 TLS) +upstream: "http://127.0.0.1:8443" + +# 规则根目录,主入口为 /waf.conf +rules_dir: "./rules" diff --git a/waf/go.mod b/waf/go.mod new file mode 100644 index 0000000..79c9898 --- /dev/null +++ b/waf/go.mod @@ -0,0 +1,29 @@ +module github.com/micro_servers/waf + +go 1.25.0 + +require ( + github.com/corazawaf/coraza/v3 v3.7.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/corazawaf/libinjection-go v0.3.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect + github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect + github.com/kaptinlin/go-i18n v0.1.4 // indirect + github.com/kaptinlin/jsonschema v0.4.6 // indirect + github.com/magefile/mage v1.17.0 // indirect + github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + rsc.io/binaryregexp v0.2.0 // indirect +) diff --git a/waf/go.sum b/waf/go.sum new file mode 100644 index 0000000..1b9bdd1 --- /dev/null +++ b/waf/go.sum @@ -0,0 +1,67 @@ +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU= +github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+QYEEM= +github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo= +github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI= +github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs= +github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY= +github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q= +github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU= +github.com/jcchavezs/mergefs v0.1.1 h1:D45R17m6dHnSVZefnhynoeZvcK2Uw0oTrRfoUOQ0S5Y= +github.com/jcchavezs/mergefs v0.1.1/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk= +github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs= +github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg= +github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64= +github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0= +github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM= +github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw= +github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/waf/internal/engine/engine.go b/waf/internal/engine/engine.go new file mode 100644 index 0000000..d9b2e27 --- /dev/null +++ b/waf/internal/engine/engine.go @@ -0,0 +1,264 @@ +// Package engine 封装 coraza WAF,提供规则加载、热加载、规则列举与单条输入检测。 +package engine + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/corazawaf/coraza/v3" + "github.com/corazawaf/coraza/v3/types" +) + +// Engine 持有当前生效的 WAF 实例,支持通过 Reload 热替换。 +type Engine struct { + mu sync.RWMutex + waf coraza.WAF + cfg *Config +} + +// Config 引擎配置。 +type Config struct { + // RulesDir 规则根目录,主入口文件为 /waf.conf + RulesDir string + // ErrorCallback 规则加载/解析错误回调 + ErrorCallback func(mr types.MatchedRule) +} + +// New 创建引擎并首次加载规则。 +func New(cfg *Config) (*Engine, error) { + e := &Engine{cfg: cfg} + if err := e.Reload(); err != nil { + return nil, err + } + return e, nil +} + +// Reload 重新加载规则文件,原子替换内部 WAF 实例。 +// 加载顺序: +// 1. /waf.conf 基础配置 +// 2. /local/*.conf 本地自定义规则(按文件名字母序) +// 3. /crs/coraza.conf OWASP CRS 入口(若存在) +// 4. /crs/rules/*.conf OWASP CRS 规则(按文件名字母序) +// +// 不使用 coraza 的 Include 指令(其路径相对于 cwd 解析易出错), +// 改为 engine 自己扫描目录逐个 WithDirectivesFromFile 加载。 +// 失败时保留旧实例不动,确保服务可用。 +func (e *Engine) Reload() error { + mainFile := filepath.Join(e.cfg.RulesDir, "waf.conf") + if _, err := os.Stat(mainFile); err != nil { + return fmt.Errorf("主规则文件不存在: %w", err) + } + + builder := coraza.NewWAFConfig() + if e.cfg.ErrorCallback != nil { + builder = builder.WithErrorCallback(e.cfg.ErrorCallback) + } + + // 1. 主配置 + builder = builder.WithDirectivesFromFile(mainFile) + + // 2. 本地规则(local/*.conf,按字母序) + localFiles, _ := filepath.Glob(filepath.Join(e.cfg.RulesDir, "local", "*.conf")) + for _, f := range localFiles { + builder = builder.WithDirectivesFromFile(f) + } + + // 3. OWASP CRS(可选,crs/coraza.conf + crs/rules/*.conf) + crsEntry := filepath.Join(e.cfg.RulesDir, "crs", "coraza.conf") + if _, err := os.Stat(crsEntry); err == nil { + builder = builder.WithDirectivesFromFile(crsEntry) + crsRuleFiles, _ := filepath.Glob(filepath.Join(e.cfg.RulesDir, "crs", "rules", "*.conf")) + for _, f := range crsRuleFiles { + builder = builder.WithDirectivesFromFile(f) + } + } + + newWAF, err := coraza.NewWAF(builder) + if err != nil { + return fmt.Errorf("规则加载失败: %w", err) + } + + e.mu.Lock() + e.waf = newWAF + e.mu.Unlock() + return nil +} + +// NewTransaction 创建一个事务,由调用方 Close。 +func (e *Engine) NewTransaction() types.Transaction { + e.mu.RLock() + defer e.mu.RUnlock() + return e.waf.NewTransaction() +} + +// CheckInput 用当前规则测试单个输入字符串,返回命中的规则 ID 列表。 +// 极客 CLI 用:waf -check "/?id=1' UNION SELECT * from users" +// 输入按原始未编码字符串处理,内部对 query 做 URL 编码以符合 coraza ParseRequestURI 要求。 +func (e *Engine) CheckInput(input string) []int { + tx := e.NewTransaction() + defer tx.Close() + + uri := input + if !strings.HasPrefix(uri, "/") { + uri = "/" + uri + } + + // 分离 path 与 query,对 query 做合法 URL 编码(空格等非法字符需转义) + // 这样 coraza ProcessURI 内部的 url.ParseRequestURI 不会失败,ARGS 才能被正确提取 + fullURI := uri + if idx := strings.Index(uri, "?"); idx >= 0 { + path := uri[:idx] + rawQuery := uri[idx+1:] + // 用 net/url 编码 query:保留 & = ,其他特殊字符转义 + var parts []string + for _, kv := range strings.Split(rawQuery, "&") { + eq := strings.Index(kv, "=") + if eq < 0 { + parts = append(parts, url.QueryEscape(kv)) + } else { + parts = append(parts, url.QueryEscape(kv[:eq])+"="+url.QueryEscape(kv[eq+1:])) + } + } + fullURI = path + "?" + strings.Join(parts, "&") + } + + // 照搬 coraza/http/middleware.go 的 processRequest 流程 + tx.ProcessConnection("127.0.0.1", 8080, "127.0.0.1", 12345) + tx.ProcessURI(fullURI, "GET", "HTTP/1.1") + tx.AddRequestHeader("Host", "localhost") + tx.SetServerName("localhost") + if it := tx.ProcessRequestHeaders(); it != nil { + return []int{it.RuleID} + } + if it, _ := tx.ProcessRequestBody(); it != nil { + return []int{it.RuleID} + } + return nil +} + +// ListRules 扫描规则目录,返回所有 SecRule 的 ID 与原始行。 +// 仅做文本扫描,不解析语义——极客用 grep 友好。 +// 处理 `\` 续行:以 `\` 结尾的行与下一行合并后再抽取 id。 +func (e *Engine) ListRules() ([]Rule, error) { + var rules []Rule + err := filepath.Walk(e.cfg.RulesDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + if !strings.HasSuffix(path, ".conf") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + rel, _ := filepath.Rel(e.cfg.RulesDir, path) + + // 先合并续行,保留每条逻辑行在原文件的起始行号 + type logicalLine struct { + startLine int + text string + } + var lines []logicalLine + rawLines := strings.Split(string(data), "\n") + for i := 0; i < len(rawLines); i++ { + start := i + 1 + cur := strings.TrimRight(rawLines[i], "\r") + // 合并以 \ 结尾的续行 + for strings.HasSuffix(cur, "\\") && i+1 < len(rawLines) { + cur = cur[:len(cur)-1] + " " + strings.TrimRight(rawLines[i+1], "\r") + i++ + } + lines = append(lines, logicalLine{startLine: start, text: cur}) + } + + for _, ll := range lines { + line := strings.TrimSpace(ll.text) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if id, ok := extractRuleID(line); ok { + rules = append(rules, Rule{ + ID: id, + File: rel, + Line: ll.startLine, + Source: line, + }) + } + } + return nil + }) + return rules, err +} + +// Rule 描述一条 SecRule 的位置信息。 +type Rule struct { + ID int `json:"id"` + File string `json:"file"` + Line int `json:"line"` + Source string `json:"source"` +} + +// extractRuleID 从一行 SecRule 中抠出 id:nnnn 字段。 +// 形如: SecRule REQUEST_URI "@rx (?i)union.*select" "id:100001,phase:1,deny" +func extractRuleID(line string) (int, bool) { + idx := strings.Index(line, "id:") + if idx < 0 { + return 0, false + } + rest := line[idx+3:] + end := 0 + for end < len(rest) { + c := rest[end] + if c < '0' || c > '9' { + break + } + end++ + } + if end == 0 { + return 0, false + } + var id int + fmt.Sscanf(rest[:end], "%d", &id) + return id, true +} + +// TestFile 仅尝试加载单个规则文件,验证语法。 +// 通过临时拼一个 Include 实现,目标文件转绝对路径以适配 coraza 解析目录。 +func TestFile(file string) error { + abs, err := filepath.Abs(file) + if err != nil { + return err + } + if _, err := os.Stat(abs); err != nil { + return fmt.Errorf("文件不存在: %w", err) + } + + tmp, err := os.CreateTemp("", "waf-test-*.conf") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + _, err = tmp.WriteString(fmt.Sprintf("Include %s\n", abs)) + tmp.Close() + if err != nil { + return err + } + + builder := coraza.NewWAFConfig().WithDirectivesFromFile(tmp.Name()) + _, err = coraza.NewWAF(builder) + if err != nil { + return fmt.Errorf("语法错误: %w", err) + } + return nil +} + +// LogError 默认错误回调,输出到 stderr。 +func LogError(mr types.MatchedRule) { + fmt.Fprintf(os.Stderr, "[coraza] rule_id=%d severity=%s msg=%s\n", + mr.Rule().ID(), mr.Rule().Severity(), mr.ErrorLog()) +} diff --git a/waf/internal/proxy/proxy.go b/waf/internal/proxy/proxy.go new file mode 100644 index 0000000..66e549d --- /dev/null +++ b/waf/internal/proxy/proxy.go @@ -0,0 +1,156 @@ +// Package proxy 封装到 gateway 的反向代理,并在请求/响应阶段接入 WAF 事务。 +package proxy + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "time" + + "github.com/micro_servers/waf/internal/engine" +) + +// Proxy 持有 ReverseProxy 与 WAF 引擎引用。 +type Proxy struct { + rp *httputil.ReverseProxy + waf *engine.Engine +} + +// New 创建代理。upstream 形如 "http://127.0.0.1:8443"。 +func New(upstream string, waf *engine.Engine) (*Proxy, error) { + target, err := url.Parse(upstream) + if err != nil { + return nil, err + } + rp := httputil.NewSingleHostReverseProxy(target) + p := &Proxy{rp: rp, waf: waf} + + // 自定义 Director:保留原 Host / X-Forwarded-For + origDirector := rp.Director + rp.Director = func(req *http.Request) { + origDirector(req) + req.Header.Set("X-Forwarded-For", req.RemoteAddr) + req.Header.Set("X-Forwarded-Proto", "https") + } + // 透传错误,不暴露内部堆栈 + rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("[proxy] upstream_error ip=%s path=%s err=%v", r.RemoteAddr, r.URL.Path, err) + http.Error(w, "upstream unavailable", http.StatusBadGateway) + } + return p, nil +} + +// ServeHTTP 实现 http.Handler。 +// 流程:ProcessRequest → 命中则 403 → 否则转发 → ProcessResponseHeaders。 +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + start := time.Now() + tx := p.waf.NewTransaction() + defer func() { + tx.ProcessLogging() + _ = tx.Close() + }() + + // 解析 RemoteAddr 中的 IP 与端口 + client := r.RemoteAddr + cport := 0 + if idx := strings.LastIndexByte(r.RemoteAddr, ':'); idx != -1 { + client = r.RemoteAddr[:idx] + cport, _ = strconv.Atoi(r.RemoteAddr[idx+1:]) + } + + // 请求阶段:照搬 coraza/http/middleware.go 的 processRequest 流程 + tx.ProcessConnection(client, cport, "", 0) + tx.ProcessURI(r.URL.String(), r.Method, r.Proto) + for k, vs := range r.Header { + for _, v := range vs { + tx.AddRequestHeader(k, v) + } + } + if r.Host != "" { + tx.AddRequestHeader("Host", r.Host) + tx.SetServerName(r.Host) + } + if it := tx.ProcessRequestHeaders(); it != nil { + p.block(w, r, it.RuleID, start) + return + } + + // 请求体:读全量喂给 coraza,再用 buffer 恢复 r.Body 给 proxy 转发 + if r.Body != nil && r.Body != http.NoBody { + bodyBytes, err := io.ReadAll(r.Body) + if err == nil { + _, _, _ = tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes)) + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + if it, _ := tx.ProcessRequestBody(); it != nil { + p.block(w, r, it.RuleID, start) + return + } + } + + // 包装 ResponseWriter 以捕获状态码与响应头 + rw := &responseRecorder{ResponseWriter: w, status: 200} + p.rp.ServeHTTP(rw, r) + + // 响应阶段:把 upstream 的 header 喂给 WAF + for k, vs := range rw.headers { + for _, v := range vs { + tx.AddResponseHeader(k, v) + } + } + tx.ProcessResponseHeaders(rw.status, "HTTP/1.1") + + log.Printf("[proxy] pass ip=%s method=%s path=%s status=%d rule=- cost=%s", + r.RemoteAddr, r.Method, r.URL.Path, rw.status, time.Since(start)) +} + +// block 命中规则时统一返回 403,并打 JSON 日志便于 jq 分析。 +func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start time.Time) { + rec := map[string]any{ + "ts": time.Now().UTC().Format(time.RFC3339), + "action": "block", + "rule_id": ruleID, + "ip": r.RemoteAddr, + "method": r.Method, + "path": r.URL.Path, + "query": r.URL.RawQuery, + "ua": r.UserAgent(), + "cost_ms": time.Since(start).Milliseconds(), + } + if raw, err := json.Marshal(rec); err == nil { + log.Println(string(raw)) + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("blocked by WAF\n")) +} + +// responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。 +type responseRecorder struct { + http.ResponseWriter + status int + headers http.Header +} + +func (r *responseRecorder) WriteHeader(code int) { + r.status = code + if r.headers == nil { + r.headers = http.Header{} + } + // 拷贝上游响应头 + for k, vs := range r.Header() { + r.headers[k] = vs + } + r.ResponseWriter.WriteHeader(code) +} + +func (r *responseRecorder) Flush() { + if f, ok := r.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} diff --git a/waf/main.go b/waf/main.go new file mode 100644 index 0000000..fd1f71b --- /dev/null +++ b/waf/main.go @@ -0,0 +1,141 @@ +// Command waf 是 micro_servers 的 WAF 入口网关。 +// +// 部署形态:客户端 ──HTTPS──▶ waf:443 ──明文 HTTP──▶ gateway:内部端口 +// WAF 终止 TLS、运行 coraza 规则匹配,未命中则转发到 gateway。 +// +// 极客操作: +// +// kill -HUP # 热加载规则 +// waf -list # 列出所有规则 +// waf -test rules/local/x.conf # 校验单文件语法 +// waf -check "/?id=1' OR '1'='1" # 用当前规则测试输入 +package main + +import ( + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "gopkg.in/yaml.v3" + + "github.com/micro_servers/waf/internal/engine" + "github.com/micro_servers/waf/internal/proxy" +) + +// Config WAF 配置文件结构。 +type Config struct { + Listen string `yaml:"listen"` // 监听地址,默认 :443 + TLSCert string `yaml:"tls_cert"` // 证书文件 + TLSKey string `yaml:"tls_key"` // 私钥文件 + Upstream string `yaml:"upstream"` // gateway 地址,如 http://127.0.0.1:8443 + RulesDir string `yaml:"rules_dir"` // 规则根目录 +} + +func main() { + var ( + configFile = flag.String("c", "config.yaml", "配置文件路径") + doList = flag.Bool("list", false, "列出所有规则后退出") + doTest = flag.String("test", "", "校验单个规则文件语法后退出") + doCheck = flag.String("check", "", "用当前规则测试输入字符串(URI/path)后退出") + ) + flag.Parse() + + cfg := loadConfig(*configFile) + + // 子命令:极客 CLI 工具 + if *doTest != "" { + if err := engine.TestFile(*doTest); err != nil { + fmt.Fprintln(os.Stderr, "FAIL:", err) + os.Exit(1) + } + fmt.Println("OK") + return + } + + eng, err := engine.New(&engine.Config{ + RulesDir: cfg.RulesDir, + ErrorCallback: engine.LogError, + }) + if err != nil { + log.Fatalf("引擎初始化失败: %v", err) + } + + if *doList { + rules, err := eng.ListRules() + if err != nil { + log.Fatal(err) + } + out, _ := json.MarshalIndent(rules, "", " ") + fmt.Println(string(out)) + return + } + if *doCheck != "" { + ids := eng.CheckInput(*doCheck) + fmt.Printf("input=%q hit_rules=%v\n", *doCheck, ids) + return + } + + // 服务模式 + p, err := proxy.New(cfg.Upstream, eng) + if err != nil { + log.Fatalf("代理初始化失败: %v", err) + } + + // SIGHUP 热加载 + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP) + go func() { + for range sigCh { + log.Println("[reload] 收到 SIGHUP,重新加载规则") + if err := eng.Reload(); err != nil { + log.Printf("[reload] 失败,保留旧规则: %v", err) + } else { + log.Println("[reload] 成功") + } + } + }() + + server := &http.Server{ + Addr: cfg.Listen, + Handler: p, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + } + + log.Printf("waf listening on %s, upstream=%s, rules=%s", + cfg.Listen, cfg.Upstream, cfg.RulesDir) + log.Printf(" - 热加载: kill -HUP %d", os.Getpid()) + log.Printf(" - 列规则: %s -c %s -list", os.Args[0], *configFile) + + if err := server.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil { + log.Fatal(err) + } +} + +func loadConfig(path string) *Config { + data, err := os.ReadFile(path) + if err != nil { + log.Fatalf("读取配置失败: %v", err) + } + var c Config + if err := yaml.Unmarshal(data, &c); err != nil { + log.Fatalf("解析配置失败: %v", err) + } + if c.Listen == "" { + c.Listen = ":443" + } + if c.RulesDir == "" { + c.RulesDir = "./rules" + } + if c.Upstream == "" { + log.Fatal("upstream 不能为空") + } + return &c +} diff --git a/waf/rules/local/custom.conf b/waf/rules/local/custom.conf new file mode 100644 index 0000000..4f57cd9 --- /dev/null +++ b/waf/rules/local/custom.conf @@ -0,0 +1,26 @@ +# custom.conf —— 本地自定义规则示例 +# ID 段:100000-199999 + +# 100001: 拦截 SQL 注入特征(UNION SELECT) +SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \ + "id:100001,phase:1,deny,status:403,log,msg:'SQL injection: UNION SELECT'" + +# 100002: 拦截 SQL 注释符 +SecRule ARGS "@rx (?i)(--|#|/\*.*\*/)" \ + "id:100002,phase:2,deny,status:403,log,msg:'SQL injection: comment'" + +# 100003: 拦截 XSS 标签 +SecRule ARGS|REQUEST_HEADERS "@rx (?i)` 热加载 +# - 校验语法: `waf -test rules/local/xxx.conf` +# +# 规则 ID 分段约定: +# 100000-199999 本地自定义规则 +# 900000-999999 OWASP CRS(由 crs/ 提供,不手改) +# +# 注意:本文件不用 Include 指令(coraza 的 Include 路径相对于 cwd 解析, +# 易出错)。engine.Reload 会先加载本文件,再扫描 local/*.conf 逐个加载。 + +# ===== 基础配置(SecLang) ===== +SecRuleEngine On +SecRequestBodyAccess On +SecResponseBodyAccess On +SecDefaultAction "phase:1,pass,log,auditlog" + +# 请求体限制 10MB +SecRequestBodyLimit 10485760 +