跳过正文
  1. 博客文章/

香橙派 Zero 3 上的 Gitea 防护:OpenResty、CrowdSec 与 OpenAppSec

··2554 字·12 分钟·
运维实战 安全 Gitea OpenResty CrowdSec OpenAppSec ARM64 Orange Pi
Zayn
作者
Zayn
专注 Kubernetes、CI/CD、可观测性等云原生技术栈,记录生产环境中的实战经验与踩坑复盘。
目录
生产环境运行在一台 2GB 香橙派 Zero 3 上。公开 Gitea 遭遇持续的历史提交、源码快照和归档枚举:请求来源几乎每次都变,单个 IP 只访问一两次,合法路径却让几十 GB 的仓库反复执行 diff、tree 和 archive 计算。高峰响应达到 7~35 秒,并伴随 Git 子进程管道错误和 HTTP 500。

生产设备
#

以下数据读取于 2026 年 7 月 29 日,用于说明本文性能数据和资源限制的硬件背景:

项目实测值
开发板Orange Pi Zero 3
SoCAllwinner H618
CPU4 × Cortex-A53,480~1416 MHz
内存2,020,616 KiB(1.93 GiB,标称 2GB)
存储29.1 GiB eMMC,根文件系统为 ext4
Swap约 987 MiB zram
系统Ubuntu 26.04 resolute,ARM64
内核6.18.38-current-sunxi64

生产机使用 Ubuntu 26.04 加 Noble OpenResty 包,这是一条经过实测的兼容路径,不是新部署的默认建议。随文部署包仍以官方支持的 Ubuntu 24.04 LTS ARM64 为参考环境。

这批流量没有 SQLi、暴力破解或异常载荷。扫描器利用公开合法路径持续触发高成本计算。本文记录如何在不影响 Git clone、LFS 和普通分支浏览的前提下控制这类请求:

  • Cloudflare 负责隐藏源站和提供未来的边缘挑战能力;
  • OpenResty 恢复真实客户端 IP,并对昂贵路径实施全站预算;
  • CrowdSec 处理已知恶意 IP、渐进封禁和虚拟补丁;
  • OpenAppSec 通过独立 NGINX sidecar 提供 ML WAF;
  • Gitea 只监听本机地址,作为最后一层业务服务。

本文中的域名、仓库名、IP、密钥和目录均已替换为示例值。限流参数来自上述 Orange Pi Zero 3 的实测起点,不应未经观测直接复制到其他环境。

复现范围与版本契约
#

前半部分记录生产故障和调试过程,文末提供可执行的参考部署。生产机使用的兼容性例外不会作为推荐默认值。

覆盖范围
#

部署包复现一个经 Cloudflare 代理的公开 Gitea HTTP 站点,完整请求链为:

Cloudflare -> Host OpenResty/CrowdSec -> OpenAppSec NGINX sidecar -> Gitea

它不包含以下内容:

  • Gitea SSH 端口。参考 Compose 默认禁用 SSH;如需启用,应另行配置密钥认证、防火墙和 SSH 日志防护。
  • 同一 OpenResty 上的其他虚拟主机。CrowdSec bouncer 和 AppSec 只对 GIT_DOMAIN 生效。
  • 旧实例数据迁移、备份恢复和高可用数据库。部署包创建新的 SQLite 实例。
  • Cloudflare 账户、DNS 记录和 TLS 证书的自动创建。
  • CrowdSec Console 注册及额外订阅 Blocklist。这些是可选增强,不影响本地 LAPI、场景和 AppSec 工作。
  • OpenAppSec Premium 能力,包括 AntiBot、IPS、文件安全和 OpenAPI Schema Validation。

推荐环境与生产实测环境
#

项目可复现参考值说明
ARM64 目标机Ubuntu 24.04 LTS、4 核、2GB RAMOpenResty 官方 Noble ARM64 软件源支持的路径
Swap至少 1GB避免 Agent 初始化和突发流量把小主机推入 OOM
持久化磁盘基础组件至少预留 1GB,Git 数据另算仓库容量、LFS 和备份必须单独规划
构建机x86_64 Linux、Docker Buildx、QEMU ARM64、至少 16GB RAM实测使用 12 线程构建
容器运行时Docker 29.1+、Compose 2.40+使用 Compose 资源限制、healthcheck 和 host IPC
生产实测机Orange Pi Zero 3,Ubuntu 26.04 ARM64Noble OpenResty 包运行正常,但属于不受官方支持的兼容性例外

部署包锁定以下输入:

组件固定版本或提交
Gitea1.27.0,镜像 digest sha256:7dff60d7...87bd64
OpenResty1.31.1.1-1~noble1
CrowdSec1.7.8
cs-openresty-bouncer1.2.0,归档 SHA-256 49b10d29...20ace8
OpenAppSec Agent1.1.34,提交 70743bcaf6eae01f1ccf7623f2bebe784716e170
OpenAppSec Attachment提交 06ef6b35d2ef4b4b99c0ab99d3239272c1a8536f
Attachment NGINX1.29.3,Alpine 3.22.2
Agent RuntimeAlpine 3.23.4

本次保留的两张ARM64镜像归档分别为:

614962f0c35a3f719965c08225b0dc3449eaceb61bb1da5ad0291c7487b156cf  openappsec-agent-1.1.34-arm64.tar.gz
74c8458e25569d1bbe295e6395976d1eca01734beb42b1f1114caa50eac76407  openappsec-nginx-attachment-1.1.34-arm64.tar.gz

这些哈希是本次保留产物的来源证明。读者重新构建时,Docker镜像元数据可能使归档哈希不同;应以构建脚本生成的本地IMAGE-SHA256SUMS为准,并继续核对架构、OCI label和源码提交,不能比较不同Docker版本解析出的image ID。

Advanced Model 前提

生产环境使用OpenAppSec Advanced Model V2.0。本次文件大小为843678字节,SHA-256为7aa1b4767a8d6de3e2160764d797a089e36bbd29931f8d36fbc1b5c47294be4b。该模型只能登录OpenAppSec Portal后从User Menu -> Download Advanced ML Model获取,受其许可证约束,不随部署包分发,也不应提交Git。Portal更新模型后,新下载文件的哈希可能不同。

部署文件
#

Page Bundle附带全部脱敏配置和脚本:

可以从当前文章地址推导下载地址。将YOUR_BLOG_HOST替换为实际站点域名:

set -Eeuo pipefail

KIT_BASE_URL="https://YOUR_BLOG_HOST/posts/gitea-layered-defense-arm64/files"
mkdir -p gitea-layered-defense-kit
cd gitea-layered-defense-kit

curl -fsSLO "$KIT_BASE_URL/SHA256SUMS"
while read -r _ relative_path; do
  mkdir -p "$(dirname "$relative_path")"
  curl -fsSL "$KIT_BASE_URL/$relative_path" -o "$relative_path"
done < SHA256SUMS
sha256sum -c SHA256SUMS

预期每个文件都显示OK。下载完成后,先阅读.env模板和脚本,再在目标机使用root权限执行。

运行结果
#

配置上线后经历三轮自然流量观察和一次浏览器完整代码浏览验证,结果如下:

指标优化前上线后观察
高峰重路径响应7~35 秒常态低于 1 秒,突发请求受预算控制
公开 HTTP 500扫描窗口内持续出现三个连续观察窗口为 0
扫描请求返回 4290峰值窗口 81.4%
普通 src/branch 浏览 429不适用0
CrowdSec LAPI/AppSec 超时突发时可复现就绪后连续窗口均为 0
Git Smart HTTP可用clone、fetch、ls-remote 均通过
容器重启/OOM不适用0 / 0

注意

严格 Prevent 模式下,OpenAppSec 的 Error Disclosure 可能将 Gitea 内部 500 转为 WAF 403。公开 access log 的 500 为 0 只能说明错误未暴露给客户端;排查后端健康仍必须同时检查 Gitea 容器日志和 OpenAppSec HTTP Transaction Handler 日志。

另一段十分钟样本包含 839 个请求、788 个来源 IP,其中 767 个 IP 只请求一次;834 个请求使用 Chrome 或 Edge 形式的 User-Agent。OpenResty 返回了 607 个 429,证明后端日志里看到的 200 只是限流后允许通过的少量流量,而不是全部扫描流量。

一、流量类型和防护目标
#

1.1 为什么按 IP 封禁几乎无效
#

常见的 CrowdSec HTTP probing 场景按“来源 IP + 目标域名”聚合,并要求同一 IP 在窗口内访问多个不同的 400、403 或 404 路径。当前扫描有三个相反特征:

  1. 大多数来源 IP 只访问一次;
  2. 请求的是确实存在的公开仓库页面;
  3. 后端正常返回 200。

因此,无论把单 IP 封禁从 4 小时延长到 24 小时,还是增加更多信誉名单,都无法直接解决问题。攻击者不需要复用已经被封的地址。

1.2 为什么两套 AppSec 也不会自动识别
#

CrowdSec AppSec 的 in-band 规则擅长虚拟补丁、已知 CVE、SQLi 和 XSS。OpenAppSec 的 Contextual ML WAF 擅长分析请求载荷和应用上下文。下面这些请求没有攻击载荷:

GET /org/large-repo/src/commit/<sha>/README.md
GET /org/large-repo/commit/<sha>?style=split&whitespace=show-all
GET /org/large-repo/commits/commit/<sha>/search?q=author:example

它们在语义上就是合法仓库读取。WAF 如果仅凭访问 commit 页面就阻断,会同时误伤正常用户。WAF 不等于反爬系统,IP 信誉也不等于行为识别。

1.3 防护目标
#

本次防护不追求“识别每一个扫描 IP”,而是建立三个可验证目标:

  • 无论来源 IP 如何轮换,昂贵路径消耗的总预算都受控;
  • 已知恶意 IP 和真实 Web 攻击在到达 Gitea 前被阻断;
  • Git 协议、LFS、普通分支浏览和登录流程保持可用。

二、请求链和职责
#

flowchart LR
    Client[Browser / Git Client / Scanner]
    CF[Cloudflare Edge]
    OR[Host OpenResty
Real IP + Rate Limit] CS[CrowdSec Engine
LAPI :8080 + AppSec :7422] OA[OpenAppSec NGINX Sidecar
127.0.0.1:19080] Agent[OpenAppSec Agent
Advanced Model V2] Gitea[Gitea
127.0.0.1:3000] Client --> CF --> OR --> OA --> Gitea OR <-->|IP decision / AppSec verdict| CS OA <-->|Shared IPC| Agent

各层职责如下:

负责什么不负责什么
Cloudflare隐藏源站、边缘挑战、Bot 能力源站内部健康检查
OpenResty真实 IP、连接数、请求速率、头部清洗判断 SQLi 语义
CrowdSecIP 决策、社区信誉、场景封禁、虚拟补丁识别所有合法爬虫
OpenAppSecWeb 攻击分析、ML 评分、阻断响应全站分布式速率预算
GiteaGit 和协作业务直接暴露公网

三、缩小 Gitea 的暴露面
#

3.1 升级到修复版本
#

安全组件无法替代应用升级。首先将 Gitea 升级到当前维护分支中包含安全修复的版本,并在升级前确认数据库迁移是否可回滚。跨次版本升级可能执行不可逆迁移,生产环境应先验证备份恢复,而不是只验证“备份文件存在”。

本文运行 Gitea 1.27.0。具体版本应以发布时的 Gitea Releases 和所用版本的配置文档为准。

3.2 HTTP 端口只绑定本机
#

下面是最小 Compose 模板。将 <PINNED_VERSION> 替换为经过验证的固定版本或 digest;不要把 Gitea HTTP 端口暴露到所有网卡:

services:
  gitea:
    image: gitea/gitea:<PINNED_VERSION>
    ports:
      - "127.0.0.1:3000:3000"
    restart: unless-stopped

验证监听地址:

ss -lntp | grep ':3000'

预期只看到 127.0.0.1:3000,而不是 0.0.0.0:3000[::]:3000

3.3 只信任真实反向代理
#

如果不使用反向代理认证,就显式关闭它;同时把受信代理限制到实际 Docker 网关或 sidecar 地址,不要使用 *

以下配置是模板,应用前必须确认 Gitea 实际看到的代理来源:

[security]
REVERSE_PROXY_TRUSTED_PROXIES = <TRUSTED_PROXY_CIDR>
ENABLE_REVERSE_PROXY_AUTHENTICATION = false

<TRUSTED_PROXY_CIDR> 替换为 Gitea 实际看到的反向代理地址或最小 CIDR。不要把客户端网段加入受信代理。

Gitea 配置变更需要完整重启。官方配置项以 Configuration Cheat Sheet 为准。

OpenResty 和 sidecar 还要清空身份注入头,防止未来误开代理认证后出现身份伪造:

proxy_set_header X-WEBAUTH-USER "";
proxy_set_header X-WEBAUTH-EMAIL "";
proxy_set_header X-WEBAUTH-FULLNAME "";

验证时向公开入口伪造 X-WEBAUTH-USER,未登录请求仍应跳转登录页,而不能获得用户身份。

四、恢复 Cloudflare 后的真实客户端 IP
#

如果源站只看到 Cloudflare 边缘地址,CrowdSec 和每客户端限流都会对错误对象做决策。下面是配置模板;必须把两个 CIDR 占位符替换为 Cloudflare 当前官方网段,并且只信任这些来源中的 CF-Connecting-IP

# Keep this list synchronized with https://www.cloudflare.com/ips/
set_real_ip_from <CLOUDFLARE_IPV4_CIDR>;
set_real_ip_from <CLOUDFLARE_IPV6_CIDR>;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;

这里有两个不能省略的安全条件:

  1. set_real_ip_from 必须覆盖 Cloudflare 当前官方网段;
  2. 源站 80/443 还应只允许 Cloudflare 或内部健康检查来源访问。

否则攻击者可以绕过 Cloudflare 直连源站,并伪造 CF-Connecting-IP。Cloudflare 官方文档也明确要求定期更新可信网段,参见 Restoring original visitor IPs

闭环验证不能只看 OpenResty 日志。给请求添加唯一 marker,并确认同一个真实客户端 IP 同时出现在:

  • OpenResty access log;
  • OpenAppSec sidecar access log;
  • Gitea application log。

五、OpenResty:把限流从“按 IP”升级为“按资源预算”
#

5.1 使用空 key 选择性启用限流
#

NGINX 的 limit_req_zonelimit_conn_zone 都不统计空 key 请求。利用这一点,可以只把昂贵路径映射到共享 key,普通页面保持空字符串:

map $uri $gitea_heavy_key {
    default "";
    ~^/(?:attachments(?:/|$)|[^/]+/[^/]+/(?:(?:archive|blame|commit|commits|compare)(?:/|$)|(?:raw|src)/commit(?:/|$))) $server_name;
}

map $uri $gitea_download_key {
    default "";
    ~^/(?:attachments(?:/|$)|[^/]+/[^/]+/(?:archive(?:/|$)|commit/[^/]+\.(?:diff|patch)$)) $server_name;
}

limit_conn_zone $server_name zone=gitea_global_conn:1m;
limit_conn_zone $binary_remote_addr zone=gitea_client_conn:10m;
limit_conn_zone $gitea_heavy_key zone=gitea_heavy_conn:1m;
limit_conn_zone $gitea_download_key zone=gitea_download_conn:1m;

limit_req_zone $binary_remote_addr zone=gitea_client_req:10m rate=10r/s;
limit_req_zone $gitea_heavy_key zone=gitea_heavy_req:1m rate=30r/m;
limit_req_zone $gitea_download_key zone=gitea_download_req:1m rate=1r/m;

$server_name 是有意选择的全站共享 key。扫描器即使每次更换 IP,也必须共同消耗同一个昂贵路径预算。与此同时,$binary_remote_addr 仍提供每客户端的基础保护。

重路径和下载路径的 limit_conn/limit_req 可以保留在同一个 location /:非目标路径经过 map 得到空 key,两种模块都不会为其计数。这样还能避免拆分为多个重叠正则 location 后改变 NGINX 路由匹配。官方语义参见 ngx_http_limit_req_modulengx_http_limit_conn_module

5.2 在 Gitea vhost 应用多层预算
#

location / {
    limit_conn gitea_global_conn 24;
    limit_conn gitea_client_conn 6;
    limit_conn gitea_heavy_conn 2;
    limit_conn gitea_download_conn 1;

    limit_req zone=gitea_client_req burst=24 nodelay;
    limit_req zone=gitea_heavy_req burst=10 nodelay;
    limit_req zone=gitea_download_req burst=1 nodelay;

    limit_conn_status 429;
    limit_req_status 429;

    proxy_pass http://gitea_backend;
    proxy_request_buffering off;
    proxy_buffering off;
}

参数需要结合仓库大小、CPU 和真实用户行为校准。本文参数的含义是:

  • 普通客户端允许短时页面资源并发;
  • 所有历史 commit、blame 和 compare 请求共享平均 30 次/分钟;
  • archive、附件和 diff/patch 下载共享平均 1 次/分钟;
  • 昂贵请求最多 2 个并发,下载最多 1 个并发。

5.3 为什么 src/branch 必须豁免
#

第一版配置把所有 /src//raw/ 都放进全站 5 次/分钟的共享桶。结果扫描确实被限制,但普通用户打开目录、文件和图片时也连续收到 429。

日志分析后发现行为存在稳定差异:

路径主要行为上线策略
/src/branch//raw/branch/正常交互浏览不进入全站重路径桶
/src/commit//raw/commit/历史快照枚举进入 30r/m 共享桶
/commit//commits//blame/diff 和历史遍历进入 30r/m 共享桶
/archive/.diff.patch带宽和 CPU 都昂贵进入 1r/m 下载桶
Git Smart HTTP、LFS自动化协议流量不做 WAF 内容检查

调整后,浏览器从仓库根目录进入子目录,再打开原始图片的完整流程均返回 200;同一时间 commit 扫描仍有超过 70% 返回 429。

全站共享 key 会影响所有用户,因此路径分类必须足够精确。先使用 limit_req_dry_run on 或日志统计观察真实流量,再进入拒绝模式,是比直接设置极低阈值更稳妥的上线方式。

六、CrowdSec:信誉、行为场景与 AppSec 各司其职
#

6.1 三种能力不是同一件事
#

CrowdSec 在这套架构中提供三类能力:

  1. CAPI 与订阅 Blocklist:阻断已经进入社区信誉库的地址;
  2. 本地 Security Engine 场景:根据日志中的重复行为产生 decision;
  3. AppSec Component:对单个 HTTP 请求执行 in-band 或 out-of-band WAF 规则。

生产检查中,三条 Console Blocklist 和 Community Blocklist 已同步到本地,合计数万条 decision;抽查的轮换扫描 IP 尚未进入名单。另一个命中 CAPI http:exploit 的已知爬虫 IP 被正常拒绝,说明云端规则和本地执行链均已生效。

CrowdSec AppSec 同样正常处理请求,但合法 commit GET 不匹配虚拟补丁或 Web 攻击规则。官方文档说明 in-band 规则只会中断命中的当前请求,长期封禁仍由场景与 decision 驱动,参见 AppSec Component

6.2 OpenResty bouncer 选择 live 模式
#

CrowdSec OpenResty bouncer 支持两种模式:

  • stream:周期性拉取全部 decision 到共享内存;
  • live:缓存未命中时按 IP 查询 LAPI。

在这台小主机上,stream endpoint 的响应时间约为 1005ms,而默认 REQUEST_TIMEOUT=1000,导致每次拉取都在临界点超时,decision 无法进入 Lua 缓存。切换到 live 后,最初 1 秒缓存又在分布式突发中制造大量并发 LAPI 查询。

生效的覆盖配置只有两行:

MODE=live
CACHE_EXPIRATION=30

30 秒缓存显著降低了 LAPI 查询量,代价是新 decision 最多延迟约 30 秒对同一 IP 生效。该取值是资源受限场景的折中,不是 CrowdSec 的通用默认值。配置语义参见 CrowdSec OpenResty bouncer

验证不能只检查 CrowdSec 进程存活,还要同时确认:

cscli metrics show lapi
cscli metrics show bouncers
cscli metrics show appsec
curl -fsS http://127.0.0.1:8080/health
ss -lnt | grep ':7422'

观察窗口内还应扫描 OpenResty error log,确保没有:

lua tcp socket read timed out
AppSec check failed
connection refused
IPC is uninitialized

6.3 对重复攻击使用渐进封禁
#

相比把所有首次封禁直接改成 24 小时,按历史 decision 次数递增更能控制共享出口和 CGNAT 误伤:

name: default_ip_remediation
filters:
  - Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
  - type: ban
    duration: 4h
duration_expr: Sprintf('%dh', (GetDecisionsCount(Alert.GetValue()) + 1) * 4)
on_success: break

效果为首次 4 小时、第二次 8 小时、第三次 12 小时。Range remediation 仍保持固定 4 小时,避免一次误判扩大为网段级长期封锁。

修改后先校验,再重启 Security Engine:

crowdsec -t
systemctl restart crowdsec

渐进公式来自 CrowdSec 官方 Profiles 文档。

七、OpenAppSec:ARM64 上采用独立 NGINX sidecar
#

7.1 为什么不能直接加载到 OpenResty
#

OpenAppSec attachment 是与 NGINX ABI 紧密绑定的动态模块。官方 ARM64 beta attachment 对应旧 NGINX 版本,不能加载到当前 OpenResty;项目也没有正式支持 OpenResty。强行加载会让 NGINX 无法启动。

因此采用 sidecar:

  • 宿主机 OpenResty 保持现有 TLS、CrowdSec 和限流;
  • OpenAppSec attachment 运行在与其构建版本一致的 NGINX 容器;
  • sidecar 监听 127.0.0.1:19080,再代理到 Gitea 127.0.0.1:3000
  • Agent 与 attachment 通过 host IPC 和共享 /dev/shm/check-point 通信。

这避免了修改生产 OpenResty ABI,也可以通过一次 upstream 切换完成上线或回滚。

7.2 ARM64 构建中的三个真实问题
#

官方当前版本没有可直接用于该组合的稳定 ARM64 镜像,因此在 x86_64 构建机上使用 Buildx/QEMU 交叉构建 1.1.34,并在真实 ARM64 目标机做集成验证。

构建阶段遇到三个值得记录的问题:

  1. Alpine 新版 CMake 拒绝上游过低的 policy version,使用 -DCMAKE_POLICY_VERSION_MINIMUM=3.5 兼容,不修改源代码;
  2. 自定义 runtime 镜像加入 procps 后,watchdog 使用的 pidof 被 procps-ng 替换,长进程名识别失败,所有 nano-service 每约 8 秒重启;移除 procps、恢复 BusyBox pidof 后稳定;
  3. QEMU user-mode 不实现 NGINX worker 需要的 io_setup(),无法完成 attachment 集成测试。交叉构建可在 x86_64 完成,但最终 IPC、NGINX worker 和 WAF canary 必须在真实 ARM64 硬件验证。

Agent 全量 CTest 使用串行模式通过 43/43。并行测试曾出现一个 QEMU 调度超时,单测串行通过后确认不是代码失败。

7.3 避免 Agent 与 attachment 的健康检查死锁
#

HTTP Transaction Handler 不是 Agent 启动时独立拉起的服务,而是在 NGINX attachment 注册 IPC 后启动。如果 Compose 要求 Agent “完全健康”后才启动 NGINX,就会形成循环等待:

sequenceDiagram
    participant Compose
    participant Agent
    participant Nginx as NGINX Attachment
    participant Handler as HTTP Transaction Handler

    Compose->>Agent: Start
    Agent->>Agent: Start core services
    Compose->>Nginx: Start after service_started
    Nginx->>Agent: Register attachment via IPC
    Agent->>Handler: Start handler
    Handler-->>Compose: Full health becomes ready

因此依赖条件必须是 service_started,部署脚本再分两阶段检查:

  1. 等待 orchestration、attachment-registrator、agent-cache 和 policy 文件;
  2. 启动 NGINX attachment,再等待 HTTP Transaction Handler 和完整健康状态。

核心 Compose 片段:

services:
  appsec-agent:
    image: local/openappsec-agent:1.1.34-arm64
    command: ["/cp-nano-agent", "--standalone"]
    ipc: host
    cpus: 2
    mem_limit: 640m
    restart: unless-stopped

  appsec-nginx:
    image: local/openappsec-nginx-attachment:1.1.34-arm64
    network_mode: host
    ipc: host
    depends_on:
      appsec-agent:
        condition: service_started
    cpus: 0.75
    mem_limit: 128m
    restart: unless-stopped

生产配置还应固定镜像 digest、限制 PID、配置日志轮转,并持久化 Agent policy、data 和日志目录。ipc: host 扩大了容器权限边界,只应在受控主机上使用。

7.4 Git Smart HTTP 与 LFS 跳过内容检查
#

Git clone、fetch、push 和 LFS 都是合法自动化协议,不应被普通浏览器 WAF 规则打断:

location ~ ^/[^/]+/[^/]+(?:\.git)?/info/refs$ {
    cp-nano-nginx-attachment off;
    include /etc/nginx/conf.d/proxy-gitea.inc;
}

location ~ ^/[^/]+/[^/]+(?:\.git)?/info/lfs(?:/.*)?$ {
    cp-nano-nginx-attachment off;
    include /etc/nginx/conf.d/proxy-gitea.inc;
}

location ~ ^/[^/]+/[^/]+(?:\.git)?/git-(?:upload|receive)-pack$ {
    cp-nano-nginx-attachment off;
    include /etc/nginx/conf.d/proxy-gitea.inc;
}

location / {
    cp-nano-nginx-attachment on;
    include /etc/nginx/conf.d/proxy-gitea.inc;
}

外层 CrowdSec IP decision 和 OpenResty 限流仍然有效;这里只跳过 OpenAppSec 内容分析。

7.5 ML 正在推理,但不要夸大动态学习
#

当前部署已加载 Advanced Model V2.0,并以 Prevent、最低置信度 medium 实时判定 Web 攻击。SQLi canary 被记录为 Very High confidence、最终评分 1000,并在到达 Gitea 前返回 403。

但“使用 ML 推理”不等于“已经形成成熟的本站动态模型”。资产状态仍显示:

{
  "learnPermanently": true,
  "confidence_levels": [],
  "confident_sets": [],
  "last_indicators_update": 0
}

这只能证明上下文学习子系统被配置为持久化,不能证明站点基线已经成熟。官方建议新资产先运行 Learn/Detect,通常需要 2~3 天有代表性的正常流量,再根据学习等级和最近事件切换 Prevent,参见 Track Learning and Move From Learn/Detect to Prevent

本案例因持续攻击和明确的高安全要求,在 canary、Git 协议和回滚验证后加速切到严格 Prevent。普通站点应先完成有代表性的 Learn/Detect 观察,再决定切换时间。

当前 standalone Compose 也没有部署 beta 状态的本地 tuning、SmartSync 和数据库组件,因此没有学习等级面板、tuning suggestions 或人工 Benign/Malicious 反馈闭环。AntiBot 同样处于 inactive,且属于 Premium 能力。它不会因为观察到大量 commit GET,就自动将这批流量识别为 Bot。

八、踩过的坑
#

失败路径现场现象根因修正
只依赖单 IP 封禁扫描持续,几乎没有本地 decision大量来源 IP 每个只请求一次使用昂贵路径全站预算
所有 /src/ 共用 5r/m正常用户浏览连续 429分支浏览与历史扫描混在同一桶只限制 src/commit,豁免 src/branch
CrowdSec bouncer stream已封 IP 仍能访问LAPI stream 响应略超 1000ms timeout切换 live 模式
live + 1 秒缓存突发时 LAPI read timeout,bouncer fail-open轮换 IP 产生大量并发查询缓存提高到 30 秒
Agent 镜像安装 procpsnano-service 每 8 秒重启procps-ng pidof 与 watchdog 不兼容使用 BusyBox pidof
depends_on: service_healthyAgent 永远不健康,NGINX不启动Handler 需要 attachment 注册后才启动改为 service_started + 两阶段 gate
QEMU 做 NGINX 集成测试worker io_setup() ENOSYSuser-mode 模拟缺少 Linux AIO真实 ARM64 硬件验证
OpenAppSec response-code-only403 body 仍有约 274KB自定义 attachment 内置 block page 路径OpenResty 仅对带 X-Event-ID 的 403 缩短正文

最后一项是当前自定义构建的兼容性修正,不是通用 OpenAppSec 配置。生产现场使用的较短过滤器已经把 274KB 正文缩短到 10 字节,但没有主动清理 Content-EncodingTransfer-Encoding。下面是评审后改进的参考模板,已写入随文部署包;它必须先通过隔离配置测试,再作为新部署默认值,不能倒推为采集生产数据时已经运行的配置:

header_filter_by_lua_block {
    local event_id = ngx.header["X-Event-ID"]
    if ngx.status == ngx.HTTP_FORBIDDEN and event_id and event_id ~= "" then
        ngx.ctx.openappsec_block = true
        ngx.header["Content-Type"] = "text/plain; charset=utf-8"
        ngx.header["Content-Length"] = nil
        ngx.header["Content-Encoding"] = nil
        ngx.header["Transfer-Encoding"] = nil
    end
}

body_filter_by_lua_block {
    if ngx.ctx.openappsec_block then
        if ngx.arg[2] then
            ngx.arg[1] = "Forbidden\n"
        else
            ngx.arg[1] = ""
        end
    end
}

生产现场的较短版本实测把 OpenAppSec 403 正文从约 274KB 降到 10 字节,普通 Gitea 403 保持原样;随文部署包进一步清除上游压缩和传输声明,并在 activate-prevent.sh 中断言正文、响应头和 X-Event-ID。隔离测试确认外层 NGINX 会为新正文重新生成合法的 Transfer-Encoding: chunked,脚本允许该值,但拒绝残留 Content-Encoding 或其他传输编码。

九、切流、回滚与验证
#

9.1 分阶段切流
#

不要同时替换反向代理、WAF、限流和应用版本。本文采用以下顺序:

  1. 在旁路端口启动 CrowdSec、OpenAppSec Agent 和 sidecar;
  2. 直接请求 127.0.0.1:19080 验证 sidecar;
  3. 确认 Git Smart HTTP 和普通页面通过;
  4. 只修改 OpenResty upstream,从 127.0.0.1:3000 切到 127.0.0.1:19080
  5. openresty -t 成功后热加载;
  6. 任一 canary 失败,自动恢复旧 upstream 并再次热加载。

回滚脚本必须在切流前就存在,并且独立于临时 staging 目录。

9.2 验证矩阵
#

检查预期结果证明什么
sidecar health200NGINX sidecar 可达
仓库首页200基础 Web 流量正常
git ls-remote返回 HEADGit Smart HTTP 正常
伪造身份头302/303 到登录页身份头已清洗
.bundle 归档404高带宽入口已关闭
SQLi canary403 + security eventOpenAppSec Prevent 生效
CrowdSec 测试规则403AppSec in-band 链路生效
普通分支/原始图片200限流没有破坏交互浏览
SQLite quick_checkok数据库基本完整
60 秒稳定性restart/OOM 为 0运行态稳定

Git 验证要使用真实协议,而不是只请求首页:

git ls-remote https://git.example.com/org/repo.git HEAD

curl -fsS \
  'https://git.example.com/org/repo.git/info/refs?service=git-upload-pack' \
  | head

数据库验证示例:

docker exec gitea \
  sqlite3 /data/gitea/gitea.db 'PRAGMA quick_check;'

稳定性观察至少记录两次快照:

docker stats --no-stream
free -h
cscli metrics show bouncers
journalctl -u crowdsec --since '5 minutes ago'

公开 access log 不是后端健康的唯一依据。每个观察窗口还应检查 Gitea 容器日志和 OpenAppSec HTTP Transaction Handler 日志,区分“后端没有产生 500”和“500 已被 Error Disclosure 转成 403”。

攻击 canary 应从与日常管理不同的测试出口执行。否则 Cloudflare 可能针对验证源地址下发 challenge,导致同一来源对其他站点的健康检查也暂时返回边缘 403。

9.3 使用 marker 验证请求链
#

CDN 200 不等于请求到达 Gitea。发送唯一查询参数:

MARKER="chain-audit-$(date +%s)"
curl -fsS "https://git.example.com/org/repo?marker=${MARKER}" >/dev/null

随后在三层日志搜索同一 marker:

grep "$MARKER" /srv/gitea-stack/openresty/logs/gitea-access.log
docker logs openappsec-nginx 2>&1 | grep "$MARKER"
docker logs gitea 2>&1 | grep "$MARKER"

三层都出现且客户端 IP 一致,才能证明 Cloudflare、OpenResty、OpenAppSec 和 Gitea 的转发及真实 IP 链路完整。

十、仍然缺少的边缘 Bot 防护
#

截至本文记录时,Cloudflare 路径级 Managed Challenge 尚未部署。它是下一步方案,不应写成当前成果。

对于这种轮换 IP、浏览器 User-Agent、合法 GET 的扫描,边缘 challenge 比源站封 IP 更合适:真实浏览器完成一次挑战后获得 cf_clearance,扫描脚本则难以继续。上线建议如下:

  1. 先在 Bot Analytics 和 Security Events 观察至少数天;
  2. 仅匹配 commit、src/commitraw/commit、blame、compare 和 archive;
  3. 明确排除 /info/refs/info/lfsgit-upload-packgit-receive-pack 和 API;
  4. 先用 Managed Challenge,不直接 Block;
  5. 将 Challenge Passage 设置在官方建议的 15~45 分钟范围内;
  6. 逐步扩大范围,并持续检查真实 Git 客户端和登录用户。

Cloudflare Enterprise Bot Management 可以使用 bot score 和 verified bot 字段;没有对应套餐时,不能照抄这些字段,应根据当前套餐使用路径级自定义规则或内置 Bot 功能。官方同样建议先观察、从小范围开始,参见 Challenge bad botsChallenge Passage

十一、可复用的工程结论
#

WAF 与反爬的职责边界
#

SQLi、XSS、CVE 探测交给 AppSec;合法路径的资源消耗交给速率和并发预算;浏览器与自动化脚本区分交给边缘 Bot/Challenge。单个组件同时承担这些职责,会造成漏拦或误伤。

分布式扫描要限制资源,不要执着于识别身份
#

当 97% 的来源 IP 只出现一次时,IP 封禁只能处理已经进入信誉库的少数地址。对昂贵路径设置全站预算,才能在身份不断变化时保持后端成本上限。

共享限流的路径边界
#

$server_name 共享 key 能抵抗轮换 IP,但也会让所有用户共享额度。把 /src/branch/src/commit 区分开,是这次从“安全但不可用”走向“安全且可用”的关键修正。

小主机不要承担重型构建
#

OpenResty 和 OpenAppSec 的 ARM64 编译放到资源充足的 x86_64 构建机;目标 ARM 主机只负责加载经过 checksum 验证的产物和真实硬件集成测试。2GB 主机上直接编译 LuaJIT/OpenResty 曾导致资源耗尽和 SSH 断开。

每个切流操作都需要反向路径
#

配置备份、语法校验、自动回滚、真实协议 canary、稳定性观察缺一不可。能够返回首页 200,只能证明最浅的一层可用。

十二、从全新机器复现
#

下面的步骤按依赖顺序执行。任何阶段没有出现预期输出,都应停止并回滚,不要带着失败状态进入下一阶段。

12.1 准备外部条件
#

开始前必须具备:

  1. 一个由Cloudflare代理的GIT_DOMAIN,SSL/TLS模式使用Full (strict)
  2. 一张源站可读取的有效TLS证书和私钥;
  3. 从OpenAppSec Portal手动下载的Advanced Model .tgz
  4. 一台Ubuntu 24.04 ARM64目标机,至少4核、2GB RAM和1GB Swap;
  5. 一台能运行Buildx/QEMU的x86_64构建机;
  6. 一个最终用于git ls-remote验收的真实公开仓库,且至少包含一次提交。

参考部署默认禁用Gitea SSH。若目标是迁移已有实例,应先单独完成数据备份、恢复演练和Gitea数据库迁移,本节不要直接覆盖已有/data

12.2 在x86_64构建机生成ARM64镜像
#

先安装Docker和Buildx,然后注册ARM64 binfmt:

docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --name openappsec-arm64 --use 2>/dev/null || \
  docker buildx use openappsec-arm64
docker buildx inspect --bootstrap

预期Platforms包含linux/arm64。然后在部署包目录运行:

./build/agent/build.sh
./build/attachment/build.sh

cat build/agent/artifacts/IMAGE-SHA256SUMS
cat build/attachment/artifacts/IMAGE-SHA256SUMS

Agent构建会串行执行CTest。预期关键输出为:

100% tests passed, 0 tests failed out of 43
build=pass

Attachment仓库没有注册CTest,构建脚本改为验证NGINX版本、ARM64 ELF、OCI source label、源码commit和nginx -t。QEMU无法验证NGINX worker的Linux AIO和真实IPC注册,最终集成测试必须在ARM64目标机进行。

12.3 在Ubuntu 24.04 ARM64安装依赖
#

Docker Engine按官方Ubuntu安装文档安装。完成后验证:

docker version
docker compose version
uname -m

预期架构为aarch64,Docker和Compose客户端、服务端均可用。

安装OpenResty官方Noble ARM64包:

sudo apt-get update
sudo apt-get install -y ca-certificates curl gettext-base git gnupg iproute2 jq logrotate patch python3

curl -fsSL https://openresty.org/package/pubkey.gpg -o /tmp/openresty-pubkey.gpg
gpg --dearmor --yes -o /tmp/openresty-keyring.gpg /tmp/openresty-pubkey.gpg
sudo install -m 0644 /tmp/openresty-keyring.gpg /usr/share/keyrings/openresty.gpg

echo 'deb [arch=arm64 signed-by=/usr/share/keyrings/openresty.gpg] https://openresty.org/package/arm64/ubuntu noble main' \
  | sudo tee /etc/apt/sources.list.d/openresty.list >/dev/null
sudo apt-get update
sudo apt-get install -y openresty=1.31.1.1-1~noble1 openresty-opm
sudo systemctl disable --now openresty
openresty -v

预期为nginx version: openresty/1.31.1.1。Ubuntu 26.04没有对应官方仓库;不要在全新部署中默认混用Noble包。确需复刻生产兼容路径时,必须设置ALLOW_UNSUPPORTED_OS=1并先在旁路机验证依赖和回滚。

安装CrowdSec官方仓库和固定版本:

curl -fsSL https://install.crowdsec.net -o /tmp/install-crowdsec.sh
sudo sh /tmp/install-crowdsec.sh
sudo apt-get update
sudo apt-get install -y crowdsec=1.7.8
crowdsec -version
curl -fsS http://127.0.0.1:8080/health

预期版本包含v1.7.8,LAPI health返回成功。prepare.sh会安装NGINX、虚拟补丁和通用AppSec集合,不需要手工复制Hub规则。

12.4 填写参数并传输产物
#

将两张镜像归档、Advanced Model和TLS文件传到目标机。例如:

sudo install -d -m 0700 /srv/gitea-security/artifacts
sudo cp openappsec-agent-1.1.34-arm64.tar.gz /srv/gitea-security/artifacts/
sudo cp openappsec-nginx-attachment-1.1.34-arm64.tar.gz /srv/gitea-security/artifacts/
sudo cp open-appsec-advanced-model.tgz /srv/gitea-security/artifacts/

复制参数模板并填写所有值:

cp env.example .env
sha256sum \
  /srv/gitea-security/artifacts/openappsec-agent-1.1.34-arm64.tar.gz \
  /srv/gitea-security/artifacts/openappsec-nginx-attachment-1.1.34-arm64.tar.gz \
  /srv/gitea-security/artifacts/open-appsec-advanced-model.tgz
${EDITOR:-vi} .env

.env中的PUBLIC_REPOSITORY可以先填写计划创建或迁移的真实仓库路径,但在切流前必须已经存在且包含HEADORIGIN_ALLOWED_*_CIDR只加入管理网或健康检查网段,不能写0.0.0.0/0::/0

12.5 执行旁路准备
#

在部署包根目录运行:

sudo ENV_FILE="$PWD/.env" ./scripts/prepare.sh

脚本会依次完成:

  1. 校验OS、ARM64、CPU、内存、Swap和磁盘;
  2. 校验证书、Advanced Model和两张镜像归档;
  3. 按架构、OCI label和源码commit验证镜像,而不是比较image ID;
  4. 以固定Docker网关启动仅监听127.0.0.1:3000的Gitea;
  5. 生成CrowdSec Bouncer API Key,安装v1.2.0 Lua文件和空错误日志补丁;
  6. 启动CrowdSec LAPI与127.0.0.1:7422 AppSec;
  7. 先等待OpenAppSec Agent核心服务,再启动Attachment并等待完整健康;
  8. detect-learn策略启动OpenAppSec;
  9. 启动仍直接代理Gitea的OpenResty。

成功输出必须为:

prepare=pass mode=detect-learn upstream=127.0.0.1:3000 backup=...

此时OpenAppSec在旁路端口127.0.0.1:19080运行,但公开流量仍直接到Gitea。 prepare.sh不会以“文件存在”作为策略就绪条件,还会核对生成版本等于detect-learn.yaml的SHA-256,并确认OpenAppSec内部字段已映射为Learn/TransparentlogToAgent=true。初始化阶段先出现的空policy.json不算成功。

创建管理员时不要让密码进入命令历史或环境变量,直接让Gitea生成20位一次性随机密码,并强制首次登录修改:

read -r -p 'Admin username: ' GITEA_ADMIN
read -r -p 'Admin email: ' GITEA_ADMIN_EMAIL

sudo docker exec --user git \
  -e GITEA_ADMIN="$GITEA_ADMIN" \
  -e GITEA_ADMIN_EMAIL="$GITEA_ADMIN_EMAIL" \
  gitea sh -ceu 'gitea admin user create --admin --username "$GITEA_ADMIN" --email "$GITEA_ADMIN_EMAIL" --random-password --random-password-length 20 --must-change-password'

命令会输出一次性密码。立即将它存入密码管理器,首次登录后按提示修改。

随后创建或迁移.env指定的真实公开仓库,并推送至少一次提交。先验证:

git ls-remote "https://$GIT_DOMAIN/$PUBLIC_REPOSITORY.git" HEAD

预期返回非空commit ID和HEAD

12.6 单点切换到OpenAppSec sidecar
#

sudo /srv/gitea-security/bin/cutover.sh

脚本只把gitea_backend127.0.0.1:3000改为127.0.0.1:19080。它会先备份配置,执行openresty -t,再热加载并检查仓库、Git协议、身份头和.bundle;任一检查失败都会自动恢复旧upstream。

成功输出:

cutover=pass upstream=127.0.0.1:19080 repo=200 git=pass identity=303 bundle=404

部分Gitea版本可能返回302而不是303,脚本同时接受二者。

12.7 完成学习期后切换Prevent
#

新站点先保持detect-learn至少2~3天,覆盖登录、代码浏览、clone、fetch、push、LFS、API、附件和大文件上传等正常流量。观察期间至少确认:

sudo docker logs openappsec-agent --since 24h
sudo cscli metrics show bouncers
sudo cscli metrics show appsec
sudo journalctl -u openresty --since '1 hour ago'

不要在存在明显误报、LAPI超时或IPC错误时进入Prevent。确认基线和业务canary后执行:

sudo /srv/gitea-security/bin/activate-prevent.sh

脚本临时把Agent CPU提高到4核,避免资源竞争导致v1beta2策略转换超时;随后断言生成策略为Prevent/Medium/High、本地审计日志开启、SQLi返回403、X-Event-ID存在且缩短后的正文为10字节。结束时CPU恢复到2核。

成功输出:

prevent=pass mode=Prevent severity=Medium action=High response=403 body=10

12.8 执行验收检查
#

sudo STABILITY_SECONDS=60 /srv/gitea-security/bin/verify.sh

预期输出:

verify=pass origin=200 public=200 git=pass identity=303 bundle=404 waf=403 db=ok stability=60s

该结果同时证明:

  • Cloudflare公开入口和本地源站入口都可用;
  • git ls-remote通过,不只是首页200;
  • 伪造身份头没有获得身份;
  • OpenAppSec canary未到达Gitea;
  • 正常marker出现在OpenResty、sidecar和Gitea三层;
  • SQLite PRAGMA quick_check返回ok
  • 三个容器在观察窗口内没有重启;
  • OpenResty日志没有LAPI timeout、AppSec失败或IPC未初始化错误。

攻击canary只通过本机--resolve发往源站,不经过Cloudflare,避免测试出口被边缘挑战污染。公网验证只发送正常仓库请求。

12.9 回滚
#

发现误报、资源异常或业务协议失败时执行:

sudo /srv/gitea-security/bin/rollback.sh

成功输出:

rollback=pass upstream=127.0.0.1:3000 openappsec=stopped backup=...

回滚只恢复Gitea直连并停止OpenAppSec,保留OpenResty真实IP、速率预算和CrowdSec防护。配置和Agent数据均保留,便于分析后重新切流。

12.10 确定性验收表
#

阶段必须出现的结果失败动作
Agent构建43/43build=pass停止,不导出镜像
Attachment构建ARM64、commit、nginx -tbuild=pass停止,不传输镜像
prepare.shdetect-learn、直连3000、三容器healthy查看backup与日志,不切流
cutover.shrepo 200、Git pass、身份302/303、bundle 404自动恢复3000
学习观察无关键误报、LAPI timeout和IPC错误保持Detect-Learn
activate-prevent.shPrevent/Medium/High、403、10字节自动恢复上一个策略
verify.shGit、WAF、marker、DB、稳定性全通过执行rollback.sh

这份runbook复现的是同一套HTTP防护架构和经过验证的参数,不承诺bit-for-bit复现会持续更新的Portal模型、Hub规则或第三方软件仓库内容。正式长期运维还必须补充数据备份恢复、证书更新、Cloudflare网段同步、2FA和容量监控。

参考资料
#

站内相关内容:Gitea Actions ActRunner 基于 Systemd 部署安装

相关文章

Outline 自用部署:Keycloak、MinIO 和 OpenResty
·1020 字·5 分钟
MinIO LDAP DevOps Outline Keycloak +4
pfSense 翻车记:一次防火墙重启引发的故障与恢复全过程
·729 字·4 分钟
运维实战 故障复盘 防火墙 故障排查 ZFS +5
把一张 RX 6400 塞进虚拟机:PVE GPU 直通 + Windows Server RDS 部署全记录
·1337 字·7 分钟
虚拟化 PVE RDS AMD VFIO +5