Compare commits

...

41 Commits

Author SHA1 Message Date
ryan 8ad4defcc7 [功能] POW 有效期优化 2026-04-25 20:49:08 +08:00
ryan d3d32a6b6b [fix] anubis 2026-04-25 19:48:30 +08:00
ryan 9c57ec2f5c [功能] POW 集成 2026-04-19 23:00:57 +08:00
ryan f8c1fe804d [修复] 修复github登录问题 2026-04-01 10:44:34 +08:00
ryan 89489c8488 [功能] 添加卸载脚本以支持彻底卸载 OpenFlare Agent 并清空本地数据 2026-04-01 10:24:05 +08:00
ryan d425e34f71 [优化] 更新默认服务器块,添加 HTTPS 支持并启用 SSL 握手拒绝 2026-04-01 10:02:40 +08:00
ryan 49472b54bf [功能] 添加域名证书绑定支持,允许为每个域名单独选择证书并优化相关逻辑 2026-04-01 09:57:40 +08:00
ryan a002d98f3a [优化] 更新域名列表输入组件,优化按钮样式并支持自定义容器类型 2026-04-01 09:34:17 +08:00
ryan 77457250cf [功能] 更新域名列表输入组件,支持为每个域名选择证书并优化相关逻辑 2026-04-01 09:27:41 +08:00
ryan cff815bd47 [功能] 支持为 HTTPS 启用多个证书,更新相关逻辑和测试 2026-03-31 14:16:32 +08:00
ryan 97fa56b1af [功能] 添加域名列表输入组件,支持动态建议和批量输入 2026-03-31 13:29:30 +08:00
ryan 355791f2e4 [优化] 文本优化 2026-03-31 13:16:00 +08:00
ryan 65ecc27907 [优化] 文本优化 2026-03-31 13:13:38 +08:00
ryan c2184affed [功能] 添加批量更新选项接口,支持一次性更新多个配置项,更新相关逻辑和测试 2026-03-30 16:48:10 +08:00
ryan 7d9190a8d8 [功能] 禁用新用户注册功能,更新相关逻辑和测试 2026-03-30 16:01:25 +08:00
ryan 4b1e75f86b [修改] 文本优化 2026-03-30 15:46:54 +08:00
ryan 25fe178cb2 [功能] 添加网站创建抽屉组件,支持域名和上游地址输入,更新相关逻辑和测试 2026-03-30 15:07:59 +08:00
ryan 383a039338 [功能] 接口与校验改造 2026-03-30 14:45:28 +08:00
ryan e39a8995f6 [功能] 添加站点名称和多域名支持到代理路由,更新相关逻辑和测试 2026-03-30 14:11:30 +08:00
ryan 894745d43a [功能] 优化节点 IP 解析逻辑,优先使用公网地址并添加相关测试 2026-03-30 13:09:30 +08:00
ryan 39d54c2fe4 [文档] 升级代理路由规则为网站配置,支持多域名绑定与共享设置 2026-03-30 11:13:57 +08:00
ryan fdadd76945 [功能] 添加抽屉组件并重构代理路由页面,优化规则创建体验 2026-03-30 10:32:49 +08:00
ryan 6e109fd3f7 [功能] 移除前端开发规范中的禁止项和测试交付要求,简化文档内容 2026-03-27 13:55:15 +08:00
ryan f14ba66a11 [功能] 更新组件库hero3.0.1 2026-03-27 13:34:35 +08:00
ryan 6b1d2e8af9 [功能] 移除代理路由页面中的缓存和请求头列,简化显示内容 2026-03-27 11:18:37 +08:00
ryan a0fff76fcb [?] update 2026-03-24 19:02:00 +08:00
ryan 4fa8f073a3 [功能] 重构代理路由页面,优化输入组件和样式 2026-03-20 23:29:18 +08:00
ryan a6787ac30d [功能] 添加新的输入、文本区域、标签和开关组件,优化样式和功能 2026-03-20 23:15:35 +08:00
ryan 2c87254bb3 [功能] 更新代理路由页面,集成新的输入和选择组件,优化域名选择逻辑 2026-03-20 22:52:57 +08:00
ryan 1fd4b22b9c [功能] 重构代理路由页面的单元测试,优化fetch模拟和输入验证逻辑 2026-03-20 22:37:02 +08:00
ryan be9744abc6 [功能] 重构代理路由页面的单元测试,优化fetch模拟和输入验证逻辑 2026-03-20 22:20:13 +08:00
ryan afd891f0f6 [功能] 添加源站管理功能,包括源站的创建、更新、删除及列表展示 2026-03-20 20:01:42 +08:00
ryan edd31da527 [功能] 添加代理路由页面的单元测试,支持通配符和精确域名的规则生成 2026-03-20 19:42:29 +08:00
ryan 7b9377eb21 [文档] 文档更新 2026-03-19 21:17:23 +08:00
ryan dc72c78b7f [优化] 界面优化 2026-03-19 21:00:43 +08:00
ryan 9eeccb5fc6 [功能] 添加数据库观测数据清理功能,支持手动和自动清理策略 2026-03-19 20:48:45 +08:00
ryan a1b3204204 [功能] 添加遗留观察性索引和表的删除逻辑,优化数据库迁移过程 2026-03-19 20:26:18 +08:00
ryan 8737e146d1 [修改] 分片逻辑修改为基于ID 2026-03-19 17:57:30 +08:00
ryan ae72f2da9a [功能] 实现数据库版本管理与迁移逻辑,确保数据库结构与版本一致性 2026-03-19 16:45:22 +08:00
ryan f26fcd028e [功能] 添加迁移遗留观察性列的功能,支持从 raw_json 填充 metadata_json 2026-03-19 16:31:05 +08:00
ryan dd49b2777d [功能] 实现节点访问日志的分片支持,优化日志查询和管理逻辑 2026-03-19 16:19:46 +08:00
127 changed files with 19831 additions and 2009 deletions
+1 -5
View File
@@ -1,5 +1 @@
blank_issues_enabled: false
contact_links:
- name: 赞赏支持
url: https://iamazing.cn/page/reward
about: 请作者喝杯咖啡,以激励作者持续开发
blank_issues_enabled: false
+4 -1
View File
@@ -46,4 +46,7 @@ go.work.sum
*.log
.DS_Store
.codex-cache
.codex-cache
/.gomodcache/
/anubis-1.25.0/
*.mmdb
+17 -17
View File
@@ -24,6 +24,12 @@
</a>
</p>
> [!NOTE]
> 当前项目处于快速迭代期,设计与实现均不稳定,请确保使用最新版本并关注更新日志。
> [!WARNING]
> 使用 root 用户初次登录系统后,务必修改默认密码 `123456`,并且确保关闭新用户注册功能。
## 为什么存在
OpenFlare 解决的是一类朴素但高频的运维问题:
@@ -112,12 +118,9 @@ services:
DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable
GIN_MODE: release
LOG_LEVEL: info
volumes:
- openflare-data:/data
volumes:
postgres-data:
openflare-data:
```
```bash
@@ -153,6 +156,17 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
安装脚本默认写入 `/opt/openflare-agent`,创建 `openflare-agent.service`,并可重复执行以重装或升级 Agent。
如需彻底卸载 Agent 并清空本地数据,可执行:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
卸载脚本会先停止并移除 `openflare-agent.service`、删除整个 `/opt/openflare-agent` 目录,然后根据卸载前保存的 `agent.json` 判断 OpenResty 安装方式:
* Docker 模式:删除对应 OpenResty 容器,并尝试移除镜像
* 本机 `openresty_path` 模式:不改动本机 OpenResty,只提示用户手动卸载
### 3. 发布第一份配置
1. 登录管理端并新增反代规则
@@ -199,17 +213,6 @@ cd openflare_agent
go run ./cmd/agent -config /path/to/agent.json
```
## 文档导航
建议按以下顺序阅读:
1. [docs/design.md](./docs/design.md)
2. [docs/development-guidelines.md](./docs/development-guidelines.md)
3. [docs/development-plan.md](./docs/development-plan.md)
4. [docs/frontend-development-guidelines.md](./docs/frontend-development-guidelines.md)
5. [docs/deployment.md](./docs/deployment.md)
6. [docs/app-config.md](./docs/app-config.md)
## 管理端与接口
管理端当前覆盖:
@@ -226,9 +229,6 @@ go run ./cmd/agent -config /path/to/agent.json
登录管理端后,可访问 Swagger UI:`/swagger/index.html`
如需重新生成 Swagger 文档,请使用与服务端依赖一致的版本:
`go install github.com/swaggo/swag/cmd/swag@v1.16.4`
## 开源协议
本项目采用 [Apache License 2.0](./LICENSE) 开源。
+11 -1
View File
@@ -57,12 +57,21 @@ go run . --port 3000 --log-dir ./logs
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
| `RegisterEnabled` | 是否允许新用户注册 | `false` |
| `PasswordRegisterEnabled` | 是否允许通过密码方式注册 | `true` |
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数(至少 1 天) | `30` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
说明:
* `DatabaseAutoCleanupEnabled` 开启后,Server 会在每天凌晨 3 点自动清理 `node_access_logs`、`node_metric_snapshots`、`node_request_reports` 三类观测数据
* `DatabaseAutoCleanupRetentionDays` 为统一保留天数,必须大于等于 1;管理端支持手动清理时留空保留天数,以直接删除对应数据集的全部历史记录
### 1.4 OpenResty 参数
OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前常用项包括:
@@ -124,7 +133,7 @@ Agent 当前支持:
| `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 |
| `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 |
| `node_name` | 节点名称 | 否 | 自动使用主机名 |
| `node_ip` | 节点 IP | 否 | 自动探测 |
| `node_ip` | 节点 IP | 否 | 自动探测,优先选择公网 IPv4;仅无公网地址时退回可用内网地址 |
| `openresty_path` | 本机 OpenResty 路径 | 否 | 空,未设置时走 Docker 模式 |
| `openresty_container_name` | Docker 模式下的容器名 | 否 | `openflare-openresty` |
| `openresty_docker_image` | Docker 模式下的镜像 | 否 | `openresty/openresty:alpine` |
@@ -148,6 +157,7 @@ Agent 当前支持:
* `agent_token` 与 `discovery_token` 不能同时为空
* `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串
* 未配置 `openresty_path` 时默认使用 Docker OpenResty 模式
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址
## 3. 维护要求
+17 -1
View File
@@ -12,7 +12,7 @@
### 1.2 Agent
* Go 1.23+
* Go 1.24+
* 对 Agent 数据目录有写权限
* 本机模式下可执行 `openresty -t` 与 `openresty -s reload`
* Docker 模式下具备 Docker 执行权限
@@ -237,6 +237,22 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
安装脚本会下载最新 Agent、生成 `agent.json`、创建 `openflare-agent.service` 并启动服务。
如需彻底卸载 Agent 并清空本地数据,可执行:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
支持参数:
* `--install-dir`
* `--service-name`
卸载脚本会先停止 Agent、移除 `openflare-agent.service`、删除整个安装目录,再根据卸载前保存的 `agent.json` 判断 OpenResty 安装方式:
* Docker 模式:删除对应容器,并尝试移除 OpenResty 镜像
* 本机 `openresty_path` 模式:不改动本机 OpenResty,仅提示用户手动卸载
## 9. 文档维护要求
部署方式、升级方式、接入模式或联调流程变化时,同步更新本文档和 `README.md`。
+14 -24
View File
@@ -9,6 +9,8 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
当前稳定能力包括:
* 反代规则管理
* 网站级配置与多域名绑定
* 源站管理与复用
* 配置预览、发布、激活与回滚
* Agent 注册、心跳、同步、应用结果上报
* OpenResty 主配置模板、性能参数与缓存参数托管
@@ -23,28 +25,6 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
* Server 保存配置与状态,不直接 SSH 管理节点
* Agent 是节点侧唯一受控落地入口
## 2. 范围边界
当前明确不做:
* 多租户
* CDN SaaS 化能力
* GeoDNS、全球调度、智能选路
* WAF、Bot 管理、限流平台化
* 灰度百分比发布、按节点差异化配置
* 对象存储、消息队列、Prometheus、ClickHouse、Kafka 等前置基础设施
* 通用日志检索平台、APM、调用链系统、任意 BI 报表
* 证书自动签发与自动续期
* 平台化抽象对象,如 `zone`、`origin_pool`、`policy`、`deployment`
边界补充:
* OpenResty 代理缓存只作为当前反代链路优化能力存在,不扩展为独立缓存产品
* 主配置文件由 Server 统一渲染并由 Agent 受控写入,不支持节点侧手工编辑后回传合并
* 节点观测聚焦运营与运维排障所需的摘要、趋势和受控窗口数据,不提供长期日志托管
新增能力如果超出上述边界,先更新本文档,再进入实现。
## 3. 技术基线
### 3.1 Server
@@ -105,6 +85,7 @@ Origin
当前有效实体:
* `proxy_routes`
* `origins`
* `config_versions`
* `nodes`
* `node_system_profiles`
@@ -119,11 +100,19 @@ Origin
稳定约束:
* 一个域名只对应一条 `proxy_routes` 规则
* `proxy_routes` 从“单域名规则”升级为“网站配置”聚合对象;一条记录对应一个网站,可绑定一个或多个域名,并共享一组站点级配置
* `proxy_routes.site_name` 是网站的业务唯一标识;新建时默认取 `domains[0]`,后续允许独立维护,不随域名改动自动重写
* `proxy_routes.domains` 至少包含一个域名,且 `domains[0]` 作为主域名;任一域名全局只能属于一个 `proxy_routes`
* 为兼容历史数据,迁移期可保留 `proxy_routes.domain` 作为 `domains[0]` 的镜像字段,但业务读写与后续扩展必须以 `site_name` + `domains` 为准
* `origins` 只保存源站地址、展示名与备注,不承载协议、端口、路径、权重或健康检查策略
* `proxy_routes` 可选关联一个 `origins` 记录,用于复用源站地址;规则仍保存完整 `origin_url` 快照以参与渲染与版本快照
* `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡
* `proxy_routes` 上游统一渲染为带 keepalive 的 named `upstream`;单上游可附带 base path 或 query 并在 `proxy_pass` 中追加,多上游仍限定为纯 `scheme://host[:port]`
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名
* `proxy_routes.domain` 必须唯一
* 网站级流量限制、反向代理与缓存配置当前按站点共享,不在同一网站内做域名级差异化配置;但 HTTPS 允许在同一站点内按域名绑定证书
* `proxy_routes.domain_cert_ids` 用于记录与 `domains` 平行的域名证书绑定;值为 `0` 表示该域名不启用 HTTPS,仅保留 HTTP
* 发布渲染时,带证书的域名按证书分组输出独立 `443 ssl` `server` 块;未绑定证书的域名不得被自动带入 HTTPS
* 发布渲染时必须将 `proxy_routes.domains` 中的全部域名一并纳入同一站点配置,避免同站点在版本快照中被拆散
* 所有上游地址都必须为合法 `http://` 或 `https://`
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`
* 全局同时只能有一个激活版本
@@ -189,3 +178,4 @@ Origin
* 产品范围或系统边界变化时更新本文档
* 已完成阶段不再以“版本计划”形式回填
* 新阶段开始前,先补设计,再进入实现
* 涉及网站级规则改造的详细需求与实施顺序,见 [docs/website-configuration-redesign.md](./website-configuration-redesign.md)
+23 -2
View File
@@ -20,7 +20,7 @@
`openflare_agent` 继续作为 Go 单体程序:
* Go 1.23+
* Go 1.24+
* 单二进制
* 节点本地执行
* `openresty_path` 优先
@@ -99,6 +99,7 @@
当前有效实体:
* `proxy_routes`
* `origins`
* `config_versions`
* `nodes`
* `node_system_profiles`
@@ -115,9 +116,17 @@
通用约束:
* 不新增平台化对象,除非设计文档明确要求
* `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
* `origins` 仅作为可复用源站地址目录,字段保持轻量;协议、端口、路径与查询参数继续归属具体 `proxy_routes`
* `proxy_routes` 以“网站配置”作为聚合边界,必须包含唯一 `site_name` 与非空 `domains` 列表;数据库内部 `id` 可继续作为技术主键,但不能替代 `site_name` 的业务唯一性
* `proxy_routes.domains` 中的每个域名都必须全局唯一;列表第一项视为主域名,创建时若未显式填写 `site_name`,则默认使用主域名
* `proxy_routes` 继续允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
* 迁移期如保留遗留 `domain` 字段,只能作为 `domains[0]` 的兼容镜像;新代码不得继续以该字段作为唯一业务输入
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`;源站地址变更时,由 service 负责同步更新引用该源站的规则快照
* `proxy_routes` 的上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
* 流量限制、反向代理与缓存配置当前都归属站点级 `proxy_routes`,同一网站内不拆分域名级差异配置
* HTTPS 的启停仍由站点级 `proxy_routes` 控制,但证书绑定必须通过与 `domains` 平行的 `domain_cert_ids` 记录逐域名保存;未绑定证书的域名不得参与 HTTPS 渲染
* `proxy_routes.cert_ids` 仅作为站点级证书集合与兼容镜像,必须由 `domain_cert_ids` 推导生成;`cert_id` 继续作为首个已使用证书的兼容镜像
* `config_versions` 必须保存完整快照与渲染结果
* 全局同时只能有一个激活版本
* 回滚通过重新激活旧版本实现
@@ -126,6 +135,18 @@
* 快照与聚合结果采用追加式模型,不覆盖历史
* 原始访问明细必须有受控保留策略
### 3.1 数据库版本与迁移
* 任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号
* 数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库
* 每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法
* 迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录
* 新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本
* 空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本
* 数据库版本元数据属于内部控制信息,必须保存在独立内部表中,不能混入业务配置表
* 如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录
* 涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试
## 4. API 与鉴权规范
### 4.1 API
+11
View File
@@ -48,3 +48,14 @@
* 部署与配置变动:更新 `README.md`、`docs/deployment.md`、`docs/app-config.md`
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文件。
## 6. 当前专项计划
已确认需要推进“网站级规则与配置界面改造”专项,详细需求、实施顺序与验收标准见 [docs/website-configuration-redesign.md](./website-configuration-redesign.md)。
本专项的执行顺序固定为:
1. 先完成数据模型与配置渲染兼容方案
2. 再调整接口、校验与版本 diff 语义
3. 然后改造规则列表与网站配置子页面
4. 最后补齐迁移、回归测试与文档联动
-26
View File
@@ -24,11 +24,6 @@
* 默认使用 App Router
* 前端必须支持 `light`、`dark`、`system` 三种主题模式
禁止:
* 引入 Semantic UI
* 新增大型 UI 框架破坏现有组件基线
* 使用 jQuery 风格 DOM 操作
## 2. 目录与分层
@@ -131,24 +126,3 @@ tests/
* 同时支持 `light`、`dark`、`system`
* 用户选择必须持久化
* 首屏尽量避免主题闪烁
## 7. 测试与交付
每个页面至少具备:
* 加载态
* 空态
* 错误态
* 成功反馈
测试要求:
* 公共工具、类型转换、主题逻辑补单元测试
* 关键页面交互补组件测试
* 核心主链路补 Playwright 或等效联调验证
交付要求:
* 构建产物保持可静态导出
* 构建结果可被 Go Server 托管
* 新页面默认通过亮色与暗色模式验收
+266
View File
@@ -0,0 +1,266 @@
# 网站配置改造需求与开发计划
## 1. 背景
当前规则模块以“一个域名对应一条规则”为中心,已经支持单域名绑定一个或多个上游,但无法表达“多个域名共享同一套站点配置”的场景。
现阶段已经出现以下真实需求:
* 多个域名指向同一站点,并共享反向代理、缓存等设置,同时允许按域名分别绑定 HTTPS 证书
* 后续希望围绕“网站”继续叠加更多功能,而不是持续在规则列表中堆积字段
* 现有抽屉式编辑界面已经不适合承载更复杂的配置结构
因此,本轮改造将 `proxy_routes` 从“单域名规则”升级为“网站配置”视角,并引入独立的配置子页面。
## 2. 目标
本轮改造的目标如下:
* 支持一个网站绑定多个域名
* 支持一个网站绑定一个或多个上游
* 引入 `site_name` 作为网站业务唯一标识
* 将原列表页的“编辑”操作替换为“配置”,进入独立子页面管理
* 将网站配置拆分为更清晰的功能分区,为后续扩展预留结构
## 3. 本轮范围
本轮仅覆盖以下站点级配置能力:
* 域名设置
* 流量限制
* 反向代理
* 缓存
## 4. 核心模型要求
### 4.1 网站标识
* `site_name` 为网站业务唯一标识
* 新建网站时,若用户未输入 `site_name`,默认取域名列表第一项
* `site_name` 在首次生成后允许独立编辑,不随域名变更自动同步,避免影响引用、跳转和审计
* 数据库内部主键可以继续使用现有数值 `id`,但业务层必须校验 `site_name` 唯一性
### 4.2 域名列表
* 网站的域名字段改为 `domains` 列表
* `domains` 至少包含一个有效域名
* `domains[0]` 视为主域名,用于列表摘要、默认展示和兼容历史逻辑
* 同一网站内域名不能重复
* 任一域名在全局只能属于一个网站
* 域名列表需要支持新增、删除和调整顺序
### 4.3 历史兼容
* 存量单域名数据迁移后应自动转换为:
`site_name = domain`
`domains = [domain]`
* 若迁移期保留旧 `domain` 字段,该字段仅作为 `domains[0]` 的兼容镜像,不再作为主要业务输入
* 版本渲染、差异预览、接口返回和前端展示都应逐步以 `site_name + domains` 为准
## 5. 功能需求
### 5.1 列表页改造
规则列表改造为“网站列表”视图,要求如下:
* 保留当前列表页入口,但展示对象改为网站
* 原“编辑”按钮替换为“配置”按钮
* 点击“配置”进入网站配置子页面
* 列表项至少展示:
`site_name`
主域名
域名数量
上游摘要
HTTPS/缓存/启用状态摘要
* 删除、发布等现有高风险操作仍保留明确确认
### 5.2 网站配置子页面
网站配置采用左右布局:
* 左侧为菜单栏,用于切换配置分区
* 右侧为当前分区的设置面板
* 默认进入“域名设置”分区
* 建议基于 App Router 子路由或稳定的 tab 路由参数实现,保证可直接访问和刷新恢复
建议左侧菜单项固定为:
1. 域名设置
2. 流量限制
3. 反向代理
4. 缓存
为降低跨分区校验干扰,每个分区应支持独立保存与反馈;若采用统一保存,也必须提供未保存修改提示。
### 5.3 域名设置
域名设置分区负责维护网站身份与域名列表,要求如下:
* 可编辑 `site_name`
* 可维护 `domains` 列表
* 可新增、删除、排序域名
* 明确提示第一项为主域名
* 每个域名可单独选择一张证书,形成与 `domains` 平行的 `domain_cert_ids`
* 若某个域名未选择证书,则该域名不启用 HTTPS
* `HTTP -> HTTPS` 跳转逻辑与域名证书绑定放在同一分区维护
* 保存前校验:
`site_name` 非空且唯一
`domains` 非空
每个域名格式合法
域名在当前站点内不重复
域名在全局不与其他网站冲突
已选择证书的域名必须被对应证书覆盖
### 5.4 流量限制
流量限制分区用于配置站点级限流,第一期要求覆盖以下字段:
* `limit_conn perserver`
* `limit_conn perip`
* `limit_rate`
要求如下:
* 采用结构化字段存储,不允许直接录入原始 Nginx 片段
* `limit_conn perserver` 与 `limit_conn perip` 为整数;空值或 `0` 视为未启用
* `limit_rate` 采用人类可读格式录入,例如 `512k`、`1m`
* 保存前进行格式校验,并在页面中提供示例说明
* 配置发布后渲染为对应的 OpenResty/Nginx 指令
### 5.5 反向代理
反向代理分区负责维护网站回源配置,要求如下:
* 支持一个或多个上游
* 至少保留一个上游
* 支持维护回源主机名 `origin_host`
* 继续兼容当前单上游带 path/query、多上游做负载均衡的模式
* 若复用 `origins` 目录,只作为地址候选来源,不改变网站配置为主的编辑模型
建议继续保留当前兼容约束:
* 单上游可附带 path/query
* 多上游模式下,上游项保持 `scheme://host[:port]` 形式
* 同一网站的多个上游在多上游模式下维持统一协议,降低渲染复杂度
### 5.6 缓存
缓存分区负责维护站点级缓存策略,要求如下:
* 支持开启或关闭缓存
* 支持多种缓存策略
* 第一阶段至少兼容当前已存在的策略:
`url`
`suffix`
`path_prefix`
`path_exact`
* 缓存规则继续采用结构化配置,不直接暴露原始 Nginx 片段
* 保持当前安全绕过逻辑,不因界面改造改变默认缓存边界
## 6. 接口与渲染要求
* 列表接口需要返回 `site_name`、`domains`、主域名、状态摘要等字段
* 详情接口需要按分区所需字段返回完整站点配置
* 更新接口需要支持按分区或按网站整体更新,但服务端必须统一做跨字段校验
* 配置 diff 不再只关注单个域名变更,还要能识别:
网站新增/删除
域名列表变更
站点级配置变更
* 发布渲染时,同一网站的全部域名必须落入同一份站点配置上下文中
* 同一网站内,带证书的域名需按证书分组生成 HTTPS `server`;未配置证书的域名只保留 HTTP
## 7. 前端实现要求
* 列表页负责导航与摘要,不再承载完整编辑表单
* 网站配置子页面中的每个分区表单继续遵循 `React Hook Form + Zod`
* API 请求统一收敛在 `lib/api/`
* 站点级数据查询与缓存继续使用 TanStack Query
* 左侧菜单切换时需要明确处理未保存状态,避免无提示丢失修改
* 页面至少覆盖加载态、空态、错误态和保存成功反馈
## 8. 数据迁移要求
实施前必须准备显式数据库迁移与校验逻辑,至少包含:
1. 新增 `site_name`、`domains` 与 `domain_cert_ids` 存储结构
2. 将旧数据从单域名回填到站点结构,并补齐逐域名证书映射
3. 为 `site_name` 建立唯一约束
4. 为域名唯一性建立可校验约束
5. 对迁移结果做一致性校验
迁移失败时,启动流程必须中止,不允许带半迁移状态继续运行。
## 9. 开发计划
### 阶段一:模型与渲染改造
目标:
* 定义网站级 `proxy_routes` 数据结构
* 完成存量数据迁移
* 调整配置渲染与发布链路,支持多域名同站点输出
交付物:
* 数据库迁移
* model/service 调整
* 配置渲染兼容实现
* 迁移与渲染测试
### 阶段二:接口与校验改造
目标:
* 更新列表、详情、创建、更新接口的数据结构
* 引入 `site_name`、`domains`、流量限制等字段校验
* 调整版本 diff 与发布预览语义
交付物:
* API 契约更新
* 服务端参数校验与错误消息
* diff/preview 适配
* 接口回归测试
### 阶段三:前端网站列表与配置子页面
目标:
* 将规则列表切换为网站列表
* 用“配置”按钮替代“编辑”按钮
* 落地左右布局的网站配置子页面与五个分区
交付物:
* 列表页 UI 改造
* 子页面路由与布局
* 域名设置、流量限制、反向代理、缓存四个分区
* 前端交互与表单测试
### 阶段四:联调、发布验证与文档收口
目标:
* 验证从创建网站到发布配置的全链路
* 验证 Agent 拉取、应用与回滚不受影响
* 收口文档与测试
交付物:
* 联调记录
* 发布/回滚回归验证
* 文档同步更新
## 10. 验收标准
满足以下条件后,本专项可视为完成:
* 可以创建一个网站,并绑定多个域名
* 一个网站可以绑定单个或多个上游
* `site_name` 唯一,且创建时默认取第一个域名
* 原列表页已用“配置”按钮替代“编辑”按钮
* 网站配置子页面已经采用左侧菜单、右侧设置的布局
* 五个分区均可独立完成基本配置与保存
* 发布后的渲染结果可正确覆盖同一网站的全部域名,并只为已绑定证书的域名生成 HTTPS 配置
* Agent 同步、应用、回滚链路不被破坏
* 迁移、接口、渲染与前端关键路径均有对应测试或等效回归验证
+1 -1
View File
@@ -1,6 +1,6 @@
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "2380de64b00e99093e16590beb91e1a0",
"agent_token": "373956188ddead1df6dd7c86cd330b73",
"data_dir": "./data",
"openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine",
+5 -1
View File
@@ -1,3 +1,7 @@
module openflare-agent
go 1.23.0
go 1.24.0
require openflare v0.0.0
replace openflare => ../openflare_server
+23 -4
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net"
"openflare/utils/geoip/iputil"
"os"
pathpkg "path"
"path/filepath"
@@ -331,6 +332,8 @@ func detectNodeIP() string {
if err != nil {
return ""
}
bestIP := ""
bestPriority := -1
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
@@ -344,11 +347,27 @@ func detectNodeIP() string {
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
continue
}
ipv4 := ipNet.IP.To4()
if ipv4 != nil {
return ipv4.String()
ipv4 := normalizeIPv4(ipNet.IP)
priority := nodeIPPriority(ipv4)
if priority > bestPriority {
bestIP = ipv4.String()
bestPriority = priority
}
if bestPriority == 2 {
return bestIP
}
}
}
return ""
return bestIP
}
func normalizeIPv4(ip net.IP) net.IP {
if ip == nil {
return nil
}
return ip.To4()
}
func nodeIPPriority(ip net.IP) int {
return iputil.Score(ip)
}
@@ -2,6 +2,7 @@ package config
import (
"encoding/json"
"net"
"os"
"path/filepath"
"testing"
@@ -326,3 +327,49 @@ func TestInitialAuthToken(t *testing.T) {
})
}
}
func TestNodeIPPriority(t *testing.T) {
tests := []struct {
name string
ip string
expected int
}{
{
name: "public ipv4 preferred",
ip: "8.8.8.8",
expected: 2,
},
{
name: "private ipv4 fallback",
ip: "10.0.0.8",
expected: 1,
},
{
name: "link local ignored",
ip: "169.254.1.10",
expected: -1,
},
{
name: "loopback ignored",
ip: "127.0.0.1",
expected: -1,
},
{
name: "nil ignored",
ip: "",
expected: -1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var parsed net.IP
if tt.ip != "" {
parsed = net.ParseIP(tt.ip)
}
if got := nodeIPPriority(parsed); got != tt.expected {
t.Fatalf("unexpected priority for %q: got %d want %d", tt.ip, got, tt.expected)
}
})
}
}
+110 -9
View File
@@ -27,6 +27,7 @@ const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
const PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
const DockerRouteConfigPath = "/etc/nginx/conf.d/openflare_routes.conf"
const DockerAccessLogPath = "/etc/nginx/conf.d/openflare_access.log"
@@ -396,6 +397,9 @@ func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, suppor
if err := m.writeCertFiles(supportFiles); err != nil {
return err
}
if err := m.writePowConfig(supportFiles); err != nil {
return err
}
if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") {
slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured")
}
@@ -450,8 +454,21 @@ func (m *Manager) EnsureLuaAssets() error {
if strings.TrimSpace(m.LuaDir) == "" {
return nil
}
files := make([]managedFile, 0, len(ManagedObservabilityLuaFiles()))
for _, file := range ManagedObservabilityLuaFiles() {
allSupportFiles := append(ManagedObservabilityLuaFiles(), ManagedPowLuaFiles()...)
existingPowConfig, err := m.readPowConfigFile()
if err != nil {
return err
}
if existingPowConfig != nil {
allSupportFiles = append(allSupportFiles, *existingPowConfig)
}
powStaticFiles, err := ManagedPowStaticFiles()
if err != nil {
return fmt.Errorf("load pow static files: %w", err)
}
allSupportFiles = append(allSupportFiles, powStaticFiles...)
files := make([]managedFile, 0, len(allSupportFiles))
for _, file := range allSupportFiles {
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
if err != nil {
return err
@@ -536,7 +553,11 @@ func (m *Manager) CurrentChecksum() (string, error) {
if m.NginxCertDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
}
files, err := m.readCertFiles()
if luaDir := m.luaRuntimePath(); luaDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", PowStaticDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, LuaDirPlaceholder)
}
files, err := m.readManagedSupportFiles()
if err != nil {
return "", err
}
@@ -693,6 +714,7 @@ type backupState struct {
RouteExisted bool
RouteData []byte
Files []protocol.SupportFile
PowConfig *protocol.SupportFile
}
type managedFile struct {
@@ -739,6 +761,11 @@ func (m *Manager) backup() (*backupState, error) {
return nil, err
}
state.Files = files
powConfig, err := m.readPowConfigFile()
if err != nil {
return nil, err
}
state.PowConfig = powConfig
slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files))
return state, nil
}
@@ -762,10 +789,12 @@ func (m *Manager) restore(state *backupState) error {
} else if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) {
return err
}
if m.CertDir == "" {
return nil
if m.CertDir != "" {
if err := m.writeManagedCertFiles(state.Files); err != nil {
return err
}
}
return m.writeManagedCertFiles(state.Files)
return m.restorePowConfig(state)
}
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
@@ -775,6 +804,26 @@ func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
return m.writeManagedCertFiles(certFiles)
}
func (m *Manager) writePowConfig(supportFiles []protocol.SupportFile) error {
if m.LuaDir == "" {
return nil
}
configPath := filepath.Join(m.LuaDir, "pow_config.json")
for _, file := range supportFiles {
if file.Path == "pow_config.json" {
if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil {
return fmt.Errorf("write pow_config.json: %w", err)
}
slog.Info("wrote pow config", "path", configPath, "size", len(file.Content))
return nil
}
}
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove pow_config.json: %w", err)
}
return nil
}
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
files := make([]managedFile, 0, len(certFiles))
for _, file := range certFiles {
@@ -836,6 +885,53 @@ func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
return files, nil
}
func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
if m.LuaDir == "" {
return nil, nil
}
configPath := filepath.Join(m.LuaDir, "pow_config.json")
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &protocol.SupportFile{
Path: "pow_config.json",
Content: string(data),
}, nil
}
func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
files, err := m.readCertFiles()
if err != nil {
return nil, err
}
powConfig, err := m.readPowConfigFile()
if err != nil {
return nil, err
}
if powConfig != nil {
files = append(files, *powConfig)
}
return files, nil
}
func (m *Manager) restorePowConfig(state *backupState) error {
if state == nil || m.LuaDir == "" {
return nil
}
configPath := filepath.Join(m.LuaDir, "pow_config.json")
if state.PowConfig == nil {
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
return os.WriteFile(configPath, []byte(state.PowConfig.Content), 0o644)
}
func (m *Manager) certFileTargetPath(relativePath string) (string, error) {
if strings.TrimSpace(m.CertDir) == "" {
return "", errors.New("cert dir 不能为空")
@@ -988,10 +1084,15 @@ func removeEmptyManagedDirs(baseDir string) error {
}
func (m *Manager) renderRouteConfig(content string) string {
if m.NginxCertDir == "" {
return content
rendered := content
if m.NginxCertDir != "" {
rendered = strings.ReplaceAll(rendered, CertDirPlaceholder, m.NginxCertDir)
}
return strings.ReplaceAll(content, CertDirPlaceholder, m.NginxCertDir)
if luaDir := m.luaRuntimePath(); luaDir != "" {
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
rendered = strings.ReplaceAll(rendered, PowStaticDirPlaceholder, luaDir+"/pow/static")
}
return rendered
}
func (m *Manager) renderMainConfig(content string) string {
@@ -736,6 +736,13 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
if !strings.Contains(string(routeData), "/etc/nginx/openflare-certs/1.crt") {
t.Fatalf("expected placeholder replacement in route config, got %s", string(routeData))
}
renderedRoute := manager.renderRouteConfig("access_by_lua_file __OPENFLARE_LUA_DIR__/pow/check.lua;\nlocation /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n")
if !strings.Contains(renderedRoute, "access_by_lua_file /etc/nginx/openflare-lua/pow/check.lua;") {
t.Fatalf("expected lua dir placeholder replacement in route config, got %s", renderedRoute)
}
if !strings.Contains(renderedRoute, "alias /etc/nginx/openflare-lua/pow/static/;") {
t.Fatalf("expected pow static dir placeholder replacement in route config, got %s", renderedRoute)
}
mainData, err := os.ReadFile(manager.MainConfigPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
@@ -867,6 +874,12 @@ func TestEnsureLuaAssetsKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
if _, err := os.Stat(filepath.Join(luaDir, "log.lua")); err != nil {
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "pow", "check.lua")); err != nil {
t.Fatalf("expected managed pow lua file to exist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "pow", "static", "js", "main.mjs")); err != nil {
t.Fatalf("expected managed pow static asset to exist, stat err = %v", err)
}
}
func TestCertFileMode(t *testing.T) {
@@ -906,6 +919,105 @@ func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
if luaInfo.Mode().Perm() != 0o644 {
t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm())
}
if _, err := os.Stat(filepath.Join(manager.LuaDir, "pow", "check.lua")); err != nil {
t.Fatalf("failed to stat pow lua file: %v", err)
}
}
func TestEnsureLuaAssetsPreservesPowConfig(t *testing.T) {
tempDir := t.TempDir()
luaDir := filepath.Join(tempDir, "lua")
if err := os.MkdirAll(luaDir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
powConfigPath := filepath.Join(luaDir, "pow_config.json")
want := `[{"domains":["pow.example.com"],"enabled":true}]`
if err := os.WriteFile(powConfigPath, []byte(want), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{LuaDir: luaDir}
if err := manager.EnsureLuaAssets(); err != nil {
t.Fatalf("EnsureLuaAssets failed: %v", err)
}
got, err := os.ReadFile(powConfigPath)
if err != nil {
t.Fatalf("expected pow_config.json to remain after EnsureLuaAssets: %v", err)
}
if string(got) != want {
t.Fatalf("unexpected pow_config.json content: got %s want %s", string(got), want)
}
}
func TestManagerCurrentChecksumIncludesPowConfig(t *testing.T) {
tempDir := t.TempDir()
mainPath := filepath.Join(tempDir, "nginx.conf")
routePath := filepath.Join(tempDir, "routes.conf")
luaDir := filepath.Join(tempDir, "lua")
manager := &Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Executor: &fakeExecutor{},
}
outcome := manager.Apply(
context.Background(),
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
)
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
value, err := manager.CurrentChecksum()
if err != nil {
t.Fatalf("CurrentChecksum failed: %v", err)
}
expected := bundleChecksum(
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
)
if value != expected {
t.Fatalf("unexpected checksum with pow config: got %s want %s", value, expected)
}
}
func TestManagedPowLuaFilesUseInternalChallengeFlow(t *testing.T) {
if !strings.Contains(openRestyPowCheckLua, `return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")`) {
t.Fatal("expected check.lua to internally execute make-challenge instead of issuing a 302 redirect")
}
if strings.Contains(openRestyPowCheckLua, "ngx.redirect(") {
t.Fatal("expected check.lua to avoid external redirects for challenge rendering")
}
if !strings.Contains(openRestyPowChallengeLua, `<h1 id="title" class="centered-div">`) {
t.Fatal("expected challenge html to include Anubis-compatible title node")
}
if !strings.Contains(openRestyPowChallengeLua, `<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>`) {
t.Fatal("expected challenge html to include Anubis-compatible progress markup")
}
if !strings.Contains(openRestyPowChallengeLua, `<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>`) {
t.Fatal("expected challenge html to force Anubis frontend to reuse the current URL as redir target")
}
if !strings.Contains(openRestyPowCheckLua, `pow_sessions:set(session_key, "1", session_ttl)`) {
t.Fatal("expected check.lua to refresh the PoW session TTL on each valid request")
}
if !strings.Contains(openRestyPowCheckLua, `ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)`) {
t.Fatal("expected check.lua to refresh the browser session cookie on each valid request")
}
if !strings.Contains(openRestyPowChallengeLua, `local session_ttl = config.session_ttl or 600`) {
t.Fatal("expected challenge.lua to default session TTL to 10 minutes")
}
if !strings.Contains(openRestyPowVerifyLua, `local session_ttl = challenge_info.session_ttl or 600`) {
t.Fatal("expected verify.lua to default session TTL to 10 minutes")
}
if !strings.Contains(openRestyPowVerifyLua, `if ngx.var.scheme == "https" then`) {
t.Fatal("expected verify.lua to only mark the session cookie as Secure for HTTPS requests")
}
}
func TestManagerRollbackRestoresCertFiles(t *testing.T) {
@@ -0,0 +1,535 @@
package nginx
import (
"embed"
"openflare-agent/internal/protocol"
"path/filepath"
"strings"
)
//go:embed pow_static
var powStaticFS embed.FS
const openRestyPowCheckLua = `local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
if base_dir and base_dir ~= "" then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
local cjson = require "cjson.safe"
local policy = require "pow.policy"
local pow_config_dict = ngx.shared.openflare_pow_config
local pow_sessions = ngx.shared.openflare_pow_sessions
local function session_cookie(value, ttl)
local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl)
if ngx.var.scheme == "https" then
cookie = cookie .. "; Secure"
end
return cookie
end
-- Lazy-load pow_config from file; reload when content changes
local function load_pow_config()
local config_paths = {
ngx.config.prefix() .. "openflare-lua/pow_config.json",
"/etc/nginx/openflare-lua/pow_config.json",
"/usr/local/openresty/nginx/conf/pow_config.json"
}
for _, config_path in ipairs(config_paths) do
local f = io.open(config_path, "r")
if f then
local content = f:read("*a")
f:close()
local current_hash = ngx.md5(content or "")
if current_hash == pow_config_dict:get("_config_hash") then
return
end
-- Clear old domain entries
local old_keys = pow_config_dict:get("_domain_keys")
if old_keys then
for domain in string.gmatch(old_keys, "[^\n]+") do
pow_config_dict:delete(domain)
end
end
local domain_keys = {}
if content and content ~= "" and content ~= "{}" then
local ok, entries = pcall(cjson.decode, content)
if ok and entries and type(entries) == "table" then
for _, entry in ipairs(entries) do
if entry.domains then
for _, domain in ipairs(entry.domains) do
pow_config_dict:set(domain, cjson.encode(entry), 0)
domain_keys[#domain_keys+1] = domain
end
end
end
end
end
pow_config_dict:set("_domain_keys", table.concat(domain_keys, "\n"), 0)
pow_config_dict:set("_config_hash", current_hash, 0)
return
end
end
end
load_pow_config()
local host = ngx.var.host
if not host or host == "" then
return
end
local config_raw = pow_config_dict:get(host)
if not config_raw then
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config then
return
end
if not route_config.enabled then
return
end
local config = route_config.config or {}
local session_ttl = config.session_ttl or 600
local uri = ngx.var.uri or ""
local ua = ngx.var.http_user_agent or ""
local remote_ip = ngx.var.remote_addr or ""
-- Check whitelist: if matched, skip PoW
local whitelist = config.whitelist or {}
if policy.match_any(remote_ip, ua, uri, whitelist) then
return
end
-- Check blacklist: if matched, require PoW
local blacklist = config.blacklist or {}
local has_blacklist = policy.has_entries(blacklist)
local need_pow = false
if has_blacklist then
need_pow = policy.match_any(remote_ip, ua, uri, blacklist)
else
-- No blacklist means all non-whitelisted need PoW
need_pow = true
end
if not need_pow then
return
end
-- Check valid session cookie
local cookie_val = ngx.var["cookie___openflare_pow"]
if cookie_val and cookie_val ~= "" then
local session_key = host .. ":" .. cookie_val
local session_data = pow_sessions:get(session_key)
if session_data then
pow_sessions:set(session_key, "1", session_ttl)
ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)
return
end
end
-- If requesting the challenge API endpoints, let them through (handled by content_by_lua)
local anubis_api_prefix = "/.within.website/x/cmd/anubis/api/"
local anubis_static_prefix = "/.within.website/x/cmd/anubis/static/"
if string.sub(uri, 1, #anubis_api_prefix) == anubis_api_prefix then
return
end
if string.sub(uri, 1, #anubis_static_prefix) == anubis_static_prefix then
return
end
-- Render the challenge page through an internal redirect so the browser stays
-- on the originally requested URL instead of seeing a 302 hop.
ngx.req.set_uri_args({
redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""),
host = host
})
return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")
`
const openRestyPowChallengeLua = `local cjson = require "cjson.safe"
local pow_config_dict = ngx.shared.openflare_pow_config
local pow_challenges = ngx.shared.openflare_pow_challenges
local function generate_entropy()
local pieces = {
tostring(ngx.now()),
tostring(ngx.worker.pid()),
tostring(math.random()),
ngx.var.remote_addr or "",
ngx.var.http_user_agent or "",
ngx.var.request_id or "",
}
return table.concat(pieces, ":")
end
local args = ngx.req.get_uri_args()
local host = args["host"] or ngx.var.host or ""
local redir = args["redir"] or ""
local config_raw = pow_config_dict:get(host)
if not config_raw then
ngx.status = 403
ngx.say("PoW not configured for this host")
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config or not route_config.enabled then
ngx.status = 403
ngx.say("PoW not enabled for this host")
return
end
local config = route_config.config or {}
local difficulty = config.difficulty or 4
local algorithm = config.algorithm or "fast"
local challenge_ttl = config.challenge_ttl or 300
local session_ttl = config.session_ttl or 600
-- Generate challenge data without depending on ngx.random_bytes, which is not
-- available in every OpenResty runtime build.
local entropy = generate_entropy()
local challenge_id = ngx.md5(entropy .. ":id")
local challenge_data = ngx.md5(entropy .. ":data-a") .. ngx.md5(entropy .. ":data-b")
-- Store challenge
local challenge_info = cjson.encode({
data = challenge_data,
difficulty = difficulty,
host = host,
redir = redir,
session_ttl = session_ttl
})
pow_challenges:set(challenge_id, challenge_info, challenge_ttl)
local static_prefix = "/.within.website/x/cmd/anubis/static/"
local title = "Making sure you're not a bot!"
local lang = "en"
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say([[<!DOCTYPE html>
<html lang="]] .. lang .. [[">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>]] .. title .. [[</title>
<link rel="stylesheet" href="]] .. static_prefix .. [[css/xess.css">
<style>
body,html{height:100%;display:flex;justify-content:center;align-items:center;margin-left:auto;margin-right:auto}
.centered-div{text-align:center}
#status{font-variant-numeric:tabular-nums}
#progress{display:none;width:min(20rem,90%);height:2rem;border-radius:1rem;overflow:hidden;margin:1rem 0 2rem;outline-offset:2px;outline:#b16286 solid 4px}
.bar-inner{background-color:#b16286;height:100%;width:0;transition:width .25s ease-in}
</style>
<script id="anubis_version" type="application/json">"openflare-pow"</script>
<script id="anubis_challenge" type="application/json">]] .. cjson.encode({
challenge = {
id = challenge_id,
randomData = challenge_data,
method = algorithm
},
rules = {
difficulty = difficulty,
algorithm = algorithm
}
}) .. [[</script>
<script id="anubis_base_prefix" type="application/json">""</script>
<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>
</head>
<body id="top">
<main>
<h1 id="title" class="centered-div">]] .. title .. [[</h1>
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src="]] .. static_prefix .. [[img/pensive.webp?cacheBuster=openflare-pow">
<p id="status">Loading...</p>
<p>This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown.</p>
<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>
<details>
<summary>Why am I seeing this?</summary>
<p>OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically.</p>
</details>
<noscript><p>JavaScript is required to pass this verification. Please enable JavaScript and reload.</p></noscript>
</div>
</main>
<script type="module" src="]] .. static_prefix .. [[js/main.mjs"></script>
</body>
</html>]])
`
const openRestyPowVerifyLua = `local cjson = require "cjson.safe"
local pow_challenges = ngx.shared.openflare_pow_challenges
local pow_sessions = ngx.shared.openflare_pow_sessions
local args = ngx.req.get_uri_args()
local challenge_id = args["id"] or ""
local response = args["response"] or ""
local nonce_str = args["nonce"] or ""
local redir = args["redir"] or ""
local elapsed = args["elapsedTime"] or ""
if challenge_id == "" or response == "" or nonce_str == "" then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "missing parameters"}))
return
end
local nonce = tonumber(nonce_str)
if not nonce then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid nonce"}))
return
end
-- Get stored challenge
local challenge_raw = pow_challenges:get(challenge_id)
if not challenge_raw then
ngx.status = 410
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "challenge expired or not found"}))
return
end
local ok, challenge_info = pcall(cjson.decode, challenge_raw)
if not ok or not challenge_info then
ngx.status = 500
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid challenge data"}))
return
end
local challenge_data = challenge_info.data or ""
local difficulty = challenge_info.difficulty or 4
local host = challenge_info.host or ngx.var.host or ""
local session_ttl = challenge_info.session_ttl or 600
-- Compute SHA-256(challenge_data + nonce)
local calc_string = challenge_data .. tostring(math.floor(nonce))
local calculated = ngx.sha1_bin ~= nil and "" or ""
-- Use resty.sha256 for proper SHA-256
local sha256 = require "resty.sha256"
local str = require "resty.string"
local hasher = sha256:new()
hasher:update(calc_string)
local hash_bytes = hasher:final()
local hash_hex = str.to_hex(hash_bytes)
-- Verify hash matches response
if hash_hex ~= string.lower(response) then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "hash mismatch"}))
return
end
-- Verify difficulty (leading zeros in hex)
local prefix = string.rep("0", difficulty)
if string.sub(hash_hex, 1, difficulty) ~= prefix then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "insufficient difficulty"}))
return
end
-- Invalidate challenge (prevent replay)
pow_challenges:delete(challenge_id)
-- Generate session token
local session_token = str.to_hex(ngx.sha1_bin(challenge_id .. ngx.now() .. tostring(ngx.worker.pid())))
-- Store session
pow_sessions:set(host .. ":" .. session_token, "1", session_ttl)
-- Set cookie. Secure cookies are not sent over HTTP, so only add Secure when
-- the current request itself is HTTPS.
local cookie = "__openflare_pow=" .. session_token .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(session_ttl)
if ngx.var.scheme == "https" then
cookie = cookie .. "; Secure"
end
ngx.header["Set-Cookie"] = cookie
if redir ~= "" then
return ngx.redirect(redir)
end
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({ok = true}))
`
const openRestyPowPolicyLua = `local M = {}
local function match_ip(remote_ip, ips)
if not ips or #ips == 0 then return false end
for _, ip in ipairs(ips) do
if ip == remote_ip then
return true
end
end
return false
end
local function match_cidr(remote_ip, cidrs)
if not cidrs or #cidrs == 0 then return false end
for _, cidr in ipairs(cidrs) do
local m, err = ngx.re.match(cidr, "^(\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3})/(\\\\d{1,2})$")
if m then
local mask_bits = tonumber(m[2])
if mask_bits and mask_bits >= 0 and mask_bits <= 32 then
local function ip_to_num(ip_str)
local parts = {}
for part in string.gmatch(ip_str, "%d+") do
parts[#parts+1] = tonumber(part) or 0
end
if #parts ~= 4 then return 0 end
return parts[1]*16777216 + parts[2]*65536 + parts[3]*256 + parts[4]
end
local remote_num = ip_to_num(remote_ip)
local net_num = ip_to_num(m[1])
if mask_bits == 0 then
return true
end
local mask = math.floor(2^(32 - mask_bits))
mask = 4294967296 - mask
if bit.band(remote_num, mask) == bit.band(net_num, mask) then
return true
end
end
end
end
return false
end
local function match_path(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, "^" .. ngx.re.gsub(pattern, "([%^%$%(%)%%%.%[%]%+%-%?])", function(c)
if c == "*" then return ".*" end
return "%" .. c
end) .. "$", "i")
if ok and match then
return true
end
end
return false
end
local function match_path_regex(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, pattern)
if ok and match then
return true
end
end
return false
end
local function match_ua(ua, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
if ua and string.find(ua, pattern, 1, true) then
return true
end
end
return false
end
function M.match_any(remote_ip, ua, uri, list)
if not list then return false end
if match_ip(remote_ip, list.ips) then return true end
if match_cidr(remote_ip, list.ip_cidrs) then return true end
if match_path(uri, list.paths) then return true end
if match_path_regex(uri, list.path_regexes) then return true end
if match_ua(ua, list.user_agents) then return true end
return false
end
function M.has_entries(list)
if not list then return false end
return (#(list.ips or {}) + #(list.ip_cidrs or {}) + #(list.paths or {}) + #(list.path_regexes or {}) + #(list.user_agents or {})) > 0
end
return M
`
func ManagedPowLuaFiles() []protocol.SupportFile {
return []protocol.SupportFile{
{Path: "pow/check.lua", Content: openRestyPowCheckLua},
{Path: "pow/challenge.lua", Content: openRestyPowChallengeLua},
{Path: "pow/verify.lua", Content: openRestyPowVerifyLua},
{Path: "pow/policy.lua", Content: openRestyPowPolicyLua},
}
}
func ManagedPowStaticFiles() ([]protocol.SupportFile, error) {
var files []protocol.SupportFile
entries, err := powStaticFS.ReadDir("pow_static")
if err != nil {
return nil, err
}
var walk func(dir string) error
walk = func(dir string) error {
entries, err := powStaticFS.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
fullPath := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return err
}
continue
}
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return err
}
// Convert pow_static/css/xess.css -> pow/static/css/xess.css
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
return nil
}
for _, entry := range entries {
fullPath := filepath.Join("pow_static", entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return nil, err
}
} else {
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return nil, err
}
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
}
return files, nil
}
@@ -0,0 +1,7 @@
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("podkova.woff2") format("woff2");
}
@@ -0,0 +1,149 @@
:root {
--body-sans-font: Geist, sans-serif;
--body-preformatted-font: Iosevka Curly Iaso, monospace;
--body-title-font: Podkova, serif;
--background: #1d2021;
--text: #f9f5d7;
--text-selection: #d3869b;
--preformatted-background: #3c3836;
--link-foreground: #b16286;
--link-background: #282828;
--blockquote-border-left: 1px solid #bdae93;
--progress-bar-outline: #b16286 solid 4px;
--progress-bar-fill: #b16286;
}
@media (prefers-color-scheme: light) {
:root {
--background: #f9f5d7;
--text: #1d2021;
--text-selection: #d3869b;
--preformatted-background: #ebdbb2;
--link-foreground: #b16286;
--link-background: #fbf1c7;
--blockquote-border-left: 1px solid #655c54;
}
}
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("./static/geist.woff2") format("woff2");
}
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("./static/podkova.woff2") format("woff2");
}
@font-face {
font-family: "Iosevka Curly";
font-style: monospace;
font-display: swap;
src: url("./static/iosevka-curly.woff2") format("woff2");
}
main {
font-family: var(--body-sans-font);
max-width: 50rem;
padding: 2rem;
margin: auto;
}
::selection {
background: var(--text-selection);
}
body {
background: var(--background);
color: var(--text);
}
body,
html {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
#progress {
display: none;
width: min(20rem, 90%);
height: 2rem;
border-radius: 1rem;
overflow: hidden;
margin: 1rem 0 2rem;
outline-offset: 2px;
outline: var(--progress-bar-outline);
}
.bar-inner {
background-color: var(--progress-bar-fill);
height: 100%;
width: 0;
transition: width 0.25s ease-in;
}
@media (prefers-reduced-motion: no-preference) {
.bar-inner {
transition: width 0.25s ease-in;
}
}
pre {
background-color: var(--preformatted-background);
padding: 1em;
border: 0;
font-family: var(--body-preformatted-font);
}
a,
a:active,
a:visited {
color: var(--link-foreground);
background-color: var(--link-background);
}
h1,
h2,
h3,
h4,
h5 {
margin-bottom: 0.1rem;
font-family: var(--body-title-font);
}
blockquote {
border-left: var(--blockquote-border-left);
margin: 0.5em 10px;
padding: 0.5em 10px;
}
footer {
text-align: center;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var k=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function n(c,b,w=5,e=null,g,u=Math.trunc(Math.max(k()/2,1))){console.debug("fast algo");let s="purejs";return window.isSecureContext&&(s="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),s="purejs"),new Promise((p,l)=>{let m=`${c.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${s}.mjs?cacheBuster=${c.version}`,f=[],d=!1,a=()=>{console.log("PoW aborted"),i(),l(new DOMException("Aborted","AbortError"))},i=()=>{d||(d=!0,f.forEach(r=>r.terminate()),e?.removeEventListener("abort",a))};if(e!=null){if(e.aborted)return a();e.addEventListener("abort",a,{once:!0})}for(let r=0;r<u;r++){let t=new Worker(m);t.onmessage=o=>{typeof o.data=="number"?g?.(o.data):(i(),p(o.data))},t.onerror=o=>{i(),l(o)},t.postMessage({data:b,difficulty:w,nonce:r,threads:u}),f.push(t)}})}var P={fast:n,slow:n};})();
//# sourceMappingURL=index.mjs.map
@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var I=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function _(e,n,s=5,o=null,i,u=Math.trunc(Math.max(I()/2,1))){console.debug("fast algo");let a="purejs";return window.isSecureContext&&(a="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),a="purejs"),new Promise((E,x)=>{let M=`${e.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${a}.mjs?cacheBuster=${e.version}`,p=[],d=!1,b=()=>{console.log("PoW aborted"),h(),x(new DOMException("Aborted","AbortError"))},h=()=>{d||(d=!0,p.forEach(c=>c.terminate()),o?.removeEventListener("abort",b))};if(o!=null){if(o.aborted)return b();o.addEventListener("abort",b,{once:!0})}for(let c=0;c<u;c++){let g=new Worker(M);g.onmessage=m=>{typeof m.data=="number"?i?.(m.data):(h(),E(m.data))},g.onerror=m=>{h(),x(m)},g.postMessage({data:n,difficulty:s,nonce:c,threads:u}),p.push(g)}})}var j={fast:_,slow:_};var v=(e="",n={})=>{let s=new URL(e,window.location.href);return Object.entries(n).forEach(([o,i])=>s.searchParams.set(o,i)),s.toString()},L=e=>{let n=document.getElementById(e);return n===null?null:JSON.parse(n.textContent)},k=(e,n,s)=>v(`${s}/.within.website/x/cmd/anubis/static/img/${e}.webp`,{cacheBuster:n});var W=async()=>document.documentElement.lang,S=async e=>{let n=L("anubis_base_prefix");if(n!==null)try{return await(await fetch(`${n}/.within.website/x/cmd/anubis/static/locales/${e}.json`)).json()}catch(s){if(console.warn(`Failed to load translations for ${e}, falling back to English`),e!=="en")return await S("en");throw s}},C=()=>{let e=L("anubis_public_url");if(e!==null)return e&&window.location.href.startsWith(e)?new URLSearchParams(window.location.search).get("redir"):window.location.href},$={},D,A=async()=>{D=await W(),$=await S(D)},r=e=>$[`js_${e}`]||$[e]||e;(async()=>{await A();let e=[{name:"Web Workers",msg:r("web_workers_error"),value:window.Worker},{name:"Cookies",msg:r("cookies_error"),value:navigator.cookieEnabled}],n=document.getElementById("status"),s=document.getElementById("image"),o=document.getElementById("title"),i=document.getElementById("progress"),u=L("anubis_version"),a=L("anubis_base_prefix"),E=document.querySelector("details"),x=!1;E&&E.addEventListener("toggle",()=>{E.open&&(x=!0)});let M=({titleMsg:l,statusMsg:f,imageSrc:w})=>{o.innerHTML=l,n.innerHTML=f,s.src=w,i.style.display="none"};n.innerHTML=r("calculating");for(let{value:l,name:f,msg:w}of e)if(!l){M({titleMsg:`${r("missing_feature")} ${f}`,statusMsg:w,imageSrc:k("reject",u,a)});return}let{challenge:p,rules:d}=L("anubis_challenge"),b=j[d.algorithm];if(!b){M({titleMsg:r("challenge_error"),statusMsg:r("challenge_error_msg"),imageSrc:k("reject",u,a)});return}n.innerHTML=`${r("calculating_difficulty")} ${d.difficulty}, `,i.style.display="inline-block";let h=document.createTextNode(`${r("speed")} 0kH/s`);n.appendChild(h);let c=0,g=!1,m=Math.pow(16,-d.difficulty);try{let l=Date.now(),{hash:f,nonce:w}=await b({basePrefix:a,version:u},p.randomData,d.difficulty,null,t=>{let y=Date.now()-l;y-c>1e3&&(c=y,h.data=`${r("speed")} ${(t/y).toFixed(3)}kH/s`);let T=Math.pow(1-m,t),P=(1-Math.pow(T,2))*100;i["aria-valuenow"]=P,i.firstElementChild!==null&&(i.firstElementChild.style.width=`${P}%`),T<.1&&!g&&(n.append(document.createElement("br"),document.createTextNode(r("verification_longer"))),g=!0)}),H=Date.now();if(console.log({hash:f,nonce:w}),x){let y=function(){let T=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:T,elapsedTime:H-l}))},t=document.getElementById("progress");t.style.display="flex",t.style.alignItems="center",t.style.justifyContent="center",t.style.height="2rem",t.style.borderRadius="1rem",t.style.cursor="pointer",t.style.background="#b16286",t.style.color="white",t.style.fontWeight="bold",t.style.outline="4px solid #b16286",t.style.outlineOffset="2px",t.style.width="min(20rem, 90%)",t.style.margin="1rem auto 2rem",t.innerHTML=r("finished_reading"),t.onclick=y,setTimeout(y,3e4)}else{let t=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:t,elapsedTime:H-l}))}}catch(l){M({titleMsg:r("calculation_error"),statusMsg:`${r("calculation_error_msg")} ${l.message}`,imageSrc:k("reject",u,a)})}})();})();
//# sourceMappingURL=main.mjs.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var h=new TextEncoder,y=async e=>{let s=h.encode(e);return await crypto.subtle.digest("SHA-256",s)},g=e=>e.reduce((s,a)=>s+a.toString(16).padStart(2,"0"),"");addEventListener("message",async({data:e})=>{let{data:s,difficulty:a,threads:d}=e,t=e.nonce,f=t===0,o=0,c=Math.floor(a/2),l=a%2!==0;for(;;){let u=await y(s+t),i=new Uint8Array(u),r=!0;for(let n=0;n<c;n++)if(i[n]!==0){r=!1;break}if(r&&l&&i[c]>>4!==0&&(r=!1),r){let n=g(i);postMessage({hash:n,data:s,difficulty:a,nonce:t});return}t+=d,o++,t%1!==0&&(t=Math.trunc(t)),f&&(o&1023)===0&&postMessage(t)}});})();
//# sourceMappingURL=sha256-webcrypto.mjs.map
@@ -0,0 +1,66 @@
{
"loading": "Loading...",
"why_am_i_seeing": "Why am I seeing this?",
"protected_by": "Protected by",
"protected_from": "From",
"made_with": "Made with ❤️ in 🇨🇦",
"mascot_design": "Mascot design by",
"ai_companies_explanation": "You are seeing this because the administrator of this website has set up Anubis to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.",
"anubis_compromise": "Anubis is a compromise. Anubis uses a Proof-of-Work scheme in the vein of Hashcash, a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.",
"hack_purpose": "Ultimately, this is a placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate.",
"simplified_explanation": "This is a measure against bots and malicious requests similar to a CAPTCHA. However, instead of having to do work yourself, your browser is given a calculation task that it has to solve to ensure that it is a valid client. This concept is called <a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">Proof of Work</a>. The task is calculated in a few seconds and you are granted access to the website. Thank you for your understanding and patience.",
"jshelter_note": "Please note that Anubis requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.",
"version_info": "This website is running Anubis version",
"try_again": "Try again",
"go_home": "Go home",
"contact_webmaster": "or if you believe you should not be blocked, please contact the webmaster at",
"connection_security": "Please wait a moment while we ensure the security of your connection.",
"javascript_required": "Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works. A no-JS solution is a work-in-progress.",
"benchmark_requires_js": "Running the benchmark tool requires JavaScript to be enabled.",
"difficulty": "Difficulty:",
"algorithm": "Algorithm:",
"compare": "Compare:",
"time": "Time",
"iters": "Iters",
"time_a": "Time A",
"iters_a": "Iters A",
"time_b": "Time B",
"iters_b": "Iters B",
"static_check_endpoint": "This is just a check endpoint for your reverse proxy to use.",
"authorization_required": "Authorization required",
"cookies_disabled": "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain",
"access_denied": "Access Denied: error code",
"dronebl_entry": "DroneBL reported an entry",
"see_dronebl_lookup": "see",
"internal_server_error": "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around",
"invalid_redirect": "Invalid redirect",
"redirect_not_parseable": "Redirect URL not parseable",
"redirect_domain_not_allowed": "Redirect domain not allowed",
"missing_required_forwarded_headers": "Missing required X-Forwarded-* headers",
"failed_to_sign_jwt": "failed to sign JWT",
"invalid_invocation": "Invalid invocation of MakeChallenge",
"client_error_browser": "Client Error: Please ensure your browser is up to date and try again later.",
"oh_noes": "Oh noes!",
"benchmarking_anubis": "Benchmarking Anubis!",
"you_are_not_a_bot": "You are not a bot!",
"making_sure_not_bot": "Making sure you're not a bot!",
"celphase": "CELPHASE",
"js_web_crypto_error": "Your browser doesn't have a functioning web.crypto element. Are you viewing this over a secure context?",
"js_web_workers_error": "Your browser doesn't support web workers (Anubis uses this to avoid freezing your browser). Do you have a plugin like JShelter installed?",
"js_cookies_error": "Your browser doesn't store cookies. Anubis uses cookies to determine which clients have passed challenges by storing a signed token in a cookie. Please enable storing cookies for this domain. The names of the cookies Anubis stores may vary without notice. Cookie names and values are not part of the public API.",
"js_context_not_secure": "Your context is not secure!",
"js_context_not_secure_msg": "Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
"js_calculating": "Calculating...",
"js_missing_feature": "Missing feature",
"js_challenge_error": "Challenge error!",
"js_challenge_error_msg": "Failed to resolve check algorithm. You may want to reload the page.",
"js_calculating_difficulty": "Calculating...<br/>Difficulty:",
"js_speed": "Speed:",
"js_verification_longer": "Verification is taking longer than expected. Please do not refresh the page.",
"js_success": "Success!",
"js_done_took": "Done! Took",
"js_iterations": "iterations",
"js_finished_reading": "I've finished reading, continue →",
"js_calculation_error": "Calculation error!",
"js_calculation_error_msg": "Failed to calculate challenge:"
}
@@ -0,0 +1,66 @@
{
"loading": "加载中...",
"why_am_i_seeing": "为什么我会看到这个?",
"protected_by": "本网站由",
"protected_from": "保护,来自",
"made_with": "在 🇨🇦 用 ❤️ 制作",
"mascot_design": "吉祥物由",
"ai_companies_explanation": "您会看到这个画面,是因为网站管理员启用了 Anubis 来保护服务器,避免 AI 公司大量爬取网站内容。这类行为会导致网站崩溃,让所有用户都无法正常访问资源。",
"anubis_compromise": "Anubis 是一种折中做法。它采用了类似 Hashcash 的工作量证明机制(Proof-of-Work),该机制最初是为了减少垃圾邮件而提出。其核心概念是:对个别用户而言,额外的计算负担可以忽略,但对大规模爬虫来说,累积起来的成本将大幅增加,从而让爬取行为变得更困难。",
"hack_purpose": "最终,这是一个占位符解决方案,以便将更多时间用于指纹识别和识别无头浏览器(例如:通过它们如何进行字体渲染),从而无需向更可能是合法用户的用户呈现挑战工作量证明页面。",
"jshelter_note": "请注意,Anubis 需要使用现代 JavaScript 功能,而像 JShelter 这类插件可能会阻挡这些功能。请为此域名停用 JShelter 或类似的插件。",
"version_info": "这个网站正在运行的 Anubis 版本为",
"try_again": "再试一次",
"go_home": "返回首页",
"contact_webmaster": "或者您觉得您不应该被封锁,请联系网站管理员于",
"connection_security": "请稍等,我们需要在继续之前检查您的连接安全性。",
"javascript_required": "很遗憾,您必须启用 JavaScript 才能通过这项验证。这是因为 AI 公司已经改变了网站托管的社会契约,因此我们必须采取这样的保护机制。无需 JavaScript 的解决方案仍在开发中。",
"benchmark_requires_js": "运行基准测试工具需要启用 JavaScript。",
"difficulty": "难度:",
"algorithm": "算法:",
"compare": "比较:",
"time": "时间",
"iters": "迭代",
"time_a": "时间 A",
"iters_a": "迭代 A",
"time_b": "时间 B",
"iters_b": "迭代 B",
"static_check_endpoint": "这是提供给您的反向代理服务器使用的检查端点。",
"authorization_required": "需要认证",
"cookies_disabled": "您的浏览器目前已禁用 Cookie,为了确认您是合法用户,Anubis 需要启用 Cookie。 请您为此域名启用 Cookie",
"access_denied": "拒绝访问:错误代码",
"dronebl_entry": "DroneBL 报告了一条记录",
"see_dronebl_lookup": "见",
"internal_server_error": "内部服务器错误:管理员错误地配置了 Anubis。 请联系管理员要求他们检查日志",
"invalid_redirect": "无效的重定向",
"redirect_not_parseable": "重定向 URL 无法解析",
"redirect_domain_not_allowed": "重定向的域名并不允许",
"failed_to_sign_jwt": "签署 JWT 失败",
"invalid_invocation": "无效的 MakeChallenge 调用",
"client_error_browser": "客户端错误:请确保您的浏览器是最新版本并稍候再试。",
"oh_noes": "哎呀糟糕了!",
"benchmarking_anubis": "正在进行 Anubis 性能测试!",
"you_are_not_a_bot": "你不是机器人!",
"making_sure_not_bot": "正在确认你是不是机器人!",
"celphase": "CELPHASE 设计",
"js_web_crypto_error": "您的浏览器无法正常使用 web.crypto 组件。您是否通过安全连接(HTTPS)查看此网站?",
"js_web_workers_error": "您的浏览器并不支持 Web workers (Anubis 使用这个来避免冻结您的浏览器 )您有安装像是 JShelter 之类的插件吗?",
"js_cookies_error": "您的浏览器无法存储 Cookie。 Anubis 会使用 Cookie 存储签署的凭证,以判断用户是否已通过验证。请为此域名启用 Cookie 存储功能。 请注意,Anubis 存储的 Cookie 名称可能会变动,且其名称与内容不属于公开 API 的一部分。",
"js_context_not_secure": "您的内容并不安全",
"js_context_not_secure_msg": "请尝试使用 HTTPS 连接,或联系网站管理员设置 HTTPS。更多信息请参见 <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>。",
"js_calculating": "计算中...",
"js_missing_feature": "缺少功能",
"js_challenge_error": "挑战错误!",
"js_challenge_error_msg": "解决检查算法失败。 您可能会想要刷新页面。",
"js_calculating_difficulty": "计算中...<br/>难度:",
"js_speed": "速度:",
"js_verification_longer": "验证所花的时间高于预期。 请不要刷新页面。",
"js_success": "成功!",
"js_done_took": "完成! 花费",
"js_iterations": "迭代",
"js_finished_reading": "我读完了,继续 →",
"js_calculation_error": "计算错误!",
"js_calculation_error_msg": "计算挑战失败:",
"missing_required_forwarded_headers": "缺少必要的 X-Forwarded-* 头",
"simplified_explanation": "这是一种类似于验证码的措施,用于防止机器人和恶意请求。但是,您无需自己动手,您的浏览器会收到一个计算任务,必须解决该任务以确保它是有效的客户端。这个概念称为<a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">工作量证明</a>。该任务在几秒钟内计算完毕,您将被授予访问网站的权限。感谢您的理解和耐心。"
}
@@ -327,18 +327,28 @@ type trafficCountItem struct {
value int64
}
const accessLogPathMaxRunes = 100
func normalizeAccessLogPath(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
return trimmed
return truncateAccessLogPath(trimmed)
}
if strings.HasPrefix(trimmed, "/") {
return trimmed
return truncateAccessLogPath(trimmed)
}
return "/" + trimmed
return truncateAccessLogPath("/" + trimmed)
}
func truncateAccessLogPath(value string) string {
runes := []rune(value)
if len(runes) <= accessLogPathMaxRunes {
return value
}
return string(runes[:accessLogPathMaxRunes])
}
func topCounts(values map[string]int64, limit int) map[string]int64 {
@@ -3,6 +3,7 @@ package observability
import (
"os"
"path/filepath"
"strings"
"testing"
"openflare-agent/internal/config"
@@ -108,6 +109,31 @@ func TestBuildTrafficObservabilityReturnsAccessLogs(t *testing.T) {
}
}
func TestBuildTrafficObservabilityTruncatesLongAccessLogPath(t *testing.T) {
tempDir := t.TempDir()
routeConfigPath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
if err := os.MkdirAll(filepath.Dir(routeConfigPath), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
logPath := filepath.Join(filepath.Dir(routeConfigPath), "openflare_access.log")
longPath := "/" + strings.Repeat("a", 140)
content := []byte(
"{\"ts\":\"2026-03-14T08:00:00Z\",\"host\":\"app.example.com\",\"path\":\"" + longPath + "\",\"remote_addr\":\"10.0.0.1\",\"status\":200}\n",
)
if err := os.WriteFile(logPath, content, 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
stateStore := state.NewStore(filepath.Join(tempDir, "state.json"))
_, accessLogs, _ := BuildTrafficObservability(&config.Config{RouteConfigPath: routeConfigPath}, stateStore, nil)
if len(accessLogs) != 1 {
t.Fatalf("expected one access log, got %+v", accessLogs)
}
if got := len([]rune(accessLogs[0].Path)); got != accessLogPathMaxRunes {
t.Fatalf("expected truncated path length %d, got %d (%q)", accessLogPathMaxRunes, got, accessLogs[0].Path)
}
}
func TestBuildTrafficReportParsesCombinedAccessLog(t *testing.T) {
tempDir := t.TempDir()
routeConfigPath := filepath.Join(tempDir, "conf.d", "openflare_routes.conf")
+3 -1
View File
@@ -31,7 +31,7 @@ var EmailVerificationEnabled = false
var GitHubOAuthEnabled = false
var WeChatAuthEnabled = false
var TurnstileCheckEnabled = false
var RegisterEnabled = true
var RegisterEnabled = false
var SMTPServer = ""
var SMTPPort = 587
@@ -55,6 +55,8 @@ var NodeOfflineThreshold = 2 * time.Minute
var AgentHeartbeatInterval = 10000 // milliseconds
var AgentUpdateRepo = "Rain-kl/OpenFlare"
var GeoIPProvider = "ipinfo"
var DatabaseAutoCleanupEnabled = false
var DatabaseAutoCleanupRetentionDays = 30
// V5 OpenResty performance settings (hot-reloadable via Option table)
var OpenRestyWorkerProcesses = "auto"
+2
View File
@@ -24,6 +24,7 @@ func AgentRegister(c *gin.Context) {
respondBadRequest(c, "")
return
}
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
var (
result *service.AgentRegistrationResponse
@@ -57,6 +58,7 @@ func AgentHeartbeat(c *gin.Context) {
respondBadRequest(c, "")
return
}
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
authNode, ok := c.Get("agent_node")
if !ok {
+34
View File
@@ -0,0 +1,34 @@
package controller
import (
"net/http"
"openflare/service"
"github.com/gin-gonic/gin"
)
// CleanupDatabaseObservability godoc
// @Summary Cleanup observability tables
// @Tags Options
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/option/database/cleanup [post]
func CleanupDatabaseObservability(c *gin.Context) {
var input service.DatabaseCleanupInput
if err := decodeOptionalJSONBody(c.Request.Body, &input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "参数错误",
"error": err.Error(),
})
return
}
result, err := service.CleanupDatabaseObservability(input)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
+15 -13
View File
@@ -23,19 +23,21 @@ func GetStatus(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"system_name": common.SystemName,
"home_page_link": common.HomePageLink,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": common.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"system_name": common.SystemName,
"home_page_link": common.HomePageLink,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": common.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"register_enabled": common.RegisterEnabled,
"password_register_enabled": common.PasswordRegisterEnabled,
},
})
return
+122
View File
@@ -22,6 +22,10 @@ var (
openRestyDurationTokenPattern = regexp.MustCompile(`^\d+[smhdwSMHDW]$`)
)
type optionBatchPayload struct {
Options []model.Option `json:"options"`
}
func validateRateLimitOption(key string, value string) error {
maxDurationSeconds := int(common.RateLimitKeyExpirationDuration.Seconds())
@@ -73,6 +77,21 @@ func validateGeoIPOption(key string, value string) error {
return nil
}
func validateDatabaseCleanupOption(key string, value string) error {
switch key {
case "DatabaseAutoCleanupEnabled":
return validateBooleanOption(key, value)
case "DatabaseAutoCleanupRetentionDays":
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 1 {
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
}
return nil
default:
return nil
}
}
func validateOpenRestyOption(key string, value string) error {
trimmed := strings.TrimSpace(value)
@@ -178,6 +197,69 @@ func validateOpenRestyOption(key string, value string) error {
}
}
func buildOptionValidationState(options []model.Option) map[string]string {
common.OptionMapRWMutex.RLock()
state := make(map[string]string, len(common.OptionMap)+len(options))
for key, value := range common.OptionMap {
state[key] = value
}
common.OptionMapRWMutex.RUnlock()
for _, option := range options {
state[option.Key] = option.Value
}
return state
}
func validateOptionWithState(option model.Option, state map[string]string) error {
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["GitHubClientId"]) == "" {
return fmt.Errorf("鏃犳硶鍚敤 GitHub OAuth锛岃鍏堝~鍏?GitHub Client ID 浠ュ強 GitHub Client Secret锛?")
}
case "WeChatAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["WeChatServerAddress"]) == "" {
return fmt.Errorf("鏃犳硶鍚敤寰俊鐧诲綍锛岃鍏堝~鍏ュ井淇$櫥褰曠浉鍏抽厤缃俊鎭紒")
}
case "TurnstileCheckEnabled":
if option.Value == "true" && strings.TrimSpace(state["TurnstileSiteKey"]) == "" {
return fmt.Errorf("鏃犳硶鍚敤 Turnstile 鏍¢獙锛岃鍏堝~鍏?Turnstile 鏍¢獙鐩稿叧閰嶇疆淇℃伅锛?")
}
}
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
return err
}
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
return err
}
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
return err
}
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
return err
}
return nil
}
func updateOptions(options []model.Option) error {
if len(options) == 0 {
return fmt.Errorf("鏃犳晥鐨勫弬鏁?")
}
state := buildOptionValidationState(options)
for _, option := range options {
if strings.TrimSpace(option.Key) == "" {
return fmt.Errorf("鏃犳晥鐨勫弬鏁?")
}
if err := validateOptionWithState(option, state); err != nil {
return err
}
}
return model.UpdateOptions(options)
}
// GetOptions godoc
// @Summary List editable options
// @Tags Options
@@ -271,6 +353,13 @@ func UpdateOption(c *gin.Context) {
})
return
}
if err = validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
err = model.UpdateOption(option.Key, option.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -285,3 +374,36 @@ func UpdateOption(c *gin.Context) {
})
return
}
// UpdateOptionsBatch godoc
// @Summary Batch update options
// @Tags Options
// @Accept json
// @Produce json
// @Param payload body optionBatchPayload true "Batch option payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/option/update-batch [post]
func UpdateOptionsBatch(c *gin.Context) {
var payload optionBatchPayload
if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil || len(payload.Options) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "鏃犳晥鐨勫弬鏁?",
})
return
}
if err := updateOptions(payload.Options); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
+128
View File
@@ -0,0 +1,128 @@
package controller
import (
"encoding/json"
"net/http"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
func GetOrigins(c *gin.Context) {
origins, err := service.ListOrigins()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": origins,
})
}
func GetOrigin(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
origin, err := service.GetOriginDetail(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": origin,
})
}
func CreateOrigin(c *gin.Context) {
var input service.OriginInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
origin, err := service.CreateOrigin(input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": origin,
})
}
func UpdateOrigin(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
var input service.OriginInput
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
origin, err := service.UpdateOrigin(uint(id), input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": origin,
})
}
func DeleteOrigin(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if err = service.DeleteOrigin(uint(id)); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
+39 -5
View File
@@ -2,10 +2,11 @@ package controller
import (
"encoding/json"
"github.com/gin-gonic/gin"
"net/http"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
// GetProxyRoutes godoc
@@ -31,6 +32,39 @@ func GetProxyRoutes(c *gin.Context) {
})
}
// GetProxyRoute godoc
// @Summary Get proxy route detail
// @Tags ProxyRoutes
// @Produce json
// @Security BearerAuth
// @Param id path int true "Route ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/proxy-routes/{id} [get]
func GetProxyRoute(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid id",
})
return
}
route, err := service.GetProxyRoute(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": route,
})
}
// CreateProxyRoute godoc
// @Summary Create proxy route
// @Tags ProxyRoutes
@@ -46,7 +80,7 @@ func CreateProxyRoute(c *gin.Context) {
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid payload",
})
return
}
@@ -81,7 +115,7 @@ func UpdateProxyRoute(c *gin.Context) {
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid id",
})
return
}
@@ -89,7 +123,7 @@ func UpdateProxyRoute(c *gin.Context) {
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid payload",
})
return
}
@@ -122,7 +156,7 @@ func DeleteProxyRoute(c *gin.Context) {
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid id",
})
return
}
+31 -6
View File
@@ -1442,6 +1442,34 @@ const docTemplate = `{
}
}
},
"/api/option/database/cleanup": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Options"
],
"summary": "Cleanup observability tables",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/option/geoip/lookup": {
"post": {
"consumes": [
@@ -2351,22 +2379,19 @@ const docTemplate = `{
"source_countries": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
"type": "integer"
}
},
"status_codes": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
"type": "integer"
}
},
"top_domains": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
"type": "integer"
}
},
"unique_visitor_count": {
+31 -6
View File
@@ -1439,6 +1439,34 @@
}
}
},
"/api/option/database/cleanup": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Options"
],
"summary": "Cleanup observability tables",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/option/geoip/lookup": {
"post": {
"consumes": [
@@ -2348,22 +2376,19 @@
"source_countries": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
"type": "integer"
}
},
"status_codes": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
"type": "integer"
}
},
"top_domains": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
"type": "integer"
}
},
"unique_visitor_count": {
+17 -3
View File
@@ -156,17 +156,14 @@ definitions:
type: integer
source_countries:
additionalProperties:
format: int64
type: integer
type: object
status_codes:
additionalProperties:
format: int64
type: integer
type: object
top_domains:
additionalProperties:
format: int64
type: integer
type: object
unique_visitor_count:
@@ -1178,6 +1175,23 @@ paths:
summary: List editable options
tags:
- Options
/api/option/database/cleanup:
post:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties: true
type: object
security:
- BearerAuth: []
summary: Cleanup observability tables
tags:
- Options
/api/option/geoip/lookup:
post:
consumes:
+5 -1
View File
@@ -1,6 +1,6 @@
module openflare
// +heroku goVersion go1.18
// +heroku goVersion go1.24
go 1.24.0
require (
@@ -28,6 +28,7 @@ require (
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/bytedance/sonic v1.11.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
@@ -58,6 +59,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -67,6 +69,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
@@ -74,6 +77,7 @@ require (
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/sharding v0.6.2 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
+10
View File
@@ -6,6 +6,8 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
@@ -76,6 +78,7 @@ github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
@@ -126,6 +129,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUKUA5CNJBE=
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
@@ -157,6 +162,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -190,6 +196,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ=
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
@@ -253,6 +261,8 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
+5
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"embed"
"fmt"
"github.com/gin-contrib/sessions"
@@ -13,6 +14,7 @@ import (
"openflare/middleware"
"openflare/model"
"openflare/router"
"openflare/service"
"openflare/utils/geoip"
"os"
"strconv"
@@ -67,6 +69,9 @@ func main() {
// Initialize options
model.InitOptionMap()
geoip.InitGeoIP()
backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background())
defer cancelBackgroundTasks()
service.StartDatabaseAutoCleanupScheduler(backgroundCtx)
// Initialize HTTP server
server := gin.Default()
@@ -0,0 +1,19 @@
package model
import "time"
const (
legacyDatabaseSchemaVersion = 1
currentDatabaseSchemaVersion = 9
databaseSchemaVersionRowID = 1
)
type DatabaseSchemaVersion struct {
ID uint `json:"id" gorm:"primaryKey"`
Version int `json:"version" gorm:"not null"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DatabaseSchemaVersion) TableName() string {
return "database_schema_versions"
}
+43 -52
View File
@@ -27,6 +27,7 @@ func registeredModels() []any {
&File{},
&User{},
&Option{},
&Origin{},
&ProxyRoute{},
&ConfigVersion{},
&Node{},
@@ -41,6 +42,12 @@ func registeredModels() []any {
}
}
func schemaMetadataModels() []any {
return []any{
&DatabaseSchemaVersion{},
}
}
func buildDBModels() ([]dbModel, error) {
models := registeredModels()
result := make([]dbModel, 0, len(models))
@@ -61,16 +68,6 @@ func buildDBModels() ([]dbModel, error) {
return result, nil
}
func migrateProxyRouteEnableHTTPSColumn(db *gorm.DB) error {
if !db.Migrator().HasTable(&ProxyRoute{}) {
return nil
}
if db.Migrator().HasColumn(&ProxyRoute{}, "enable_https") || !db.Migrator().HasColumn(&ProxyRoute{}, "enable_http_s") {
return nil
}
return db.Migrator().RenameColumn(&ProxyRoute{}, "enable_http_s", "enable_https")
}
func createRootAccountIfNeed() error {
var user User
//if user.Status != common.UserStatusEnabled {
@@ -99,17 +96,13 @@ func CountTable(tableName string) (num int64) {
func openDatabase() (*gorm.DB, string, error) {
if common.SQLDSN != "" {
db, err := gorm.Open(postgres.Open(common.SQLDSN), &gorm.Config{
PrepareStmt: true,
})
db, err := gorm.Open(postgres.Open(common.SQLDSN), &gorm.Config{})
if err != nil {
return nil, "", err
}
return db, "postgres", nil
}
db, err := gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{
PrepareStmt: true,
})
db, err := gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{})
if err != nil {
return nil, "", err
}
@@ -126,37 +119,32 @@ func autoMigrateAll(db *gorm.DB) error {
return nil
}
func migrateTextColumns(db *gorm.DB, backend string) error {
if backend != "postgres" {
return nil
func isDatabaseEmpty(db *gorm.DB) (bool, error) {
models, err := buildDBModels()
if err != nil {
return false, err
}
type textColumn struct {
model any
table string
column string
}
columns := []textColumn{
{model: &Node{}, table: "nodes", column: "openresty_message"},
{model: &Node{}, table: "nodes", column: "last_error"},
{model: &ApplyLog{}, table: "apply_logs", column: "message"},
{model: &NodeHealthEvent{}, table: "node_health_events", column: "message"},
}
for _, item := range columns {
if !db.Migrator().HasTable(item.model) || !db.Migrator().HasColumn(item.model, item.column) {
for _, item := range models {
if isShardedObservabilityTable(item.tableName) {
for _, table := range observabilityShardTables(item.tableName) {
if !db.Migrator().HasTable(table) {
continue
}
var count int64
if err := db.Table(table).Limit(1).Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return false, nil
}
}
continue
}
sql := fmt.Sprintf(`ALTER TABLE "%s" ALTER COLUMN "%s" TYPE text`, item.table, item.column)
if err := db.Exec(sql).Error; err != nil {
return fmt.Errorf("migrate column %s.%s to text failed: %w", item.table, item.column, err)
if !db.Migrator().HasTable(item.value) {
continue
}
}
return nil
}
func isDatabaseEmpty(db *gorm.DB) (bool, error) {
for _, item := range registeredModels() {
var count int64
if err := db.Model(item).Limit(1).Count(&count).Error; err != nil {
if err := db.Model(item.value).Limit(1).Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
@@ -265,8 +253,17 @@ func migrateTableData(source *gorm.DB, target *gorm.DB, item dbModel) error {
if batchLen == 0 {
break
}
if err := target.Create(batchPtr.Interface()).Error; err != nil {
return fmt.Errorf("write target table %s failed: %w", item.tableName, err)
if isShardedObservabilityTable(item.tableName) {
for index := 0; index < batchLen; index++ {
record := batchPtr.Elem().Index(index)
if err := target.Create(record.Addr().Interface()).Error; err != nil {
return fmt.Errorf("write target sharded table %s failed: %w", item.tableName, err)
}
}
} else {
if err := target.Create(batchPtr.Interface()).Error; err != nil {
return fmt.Errorf("write target table %s failed: %w", item.tableName, err)
}
}
migrated += int64(batchLen)
offset += batchLen
@@ -293,16 +290,10 @@ func InitDB() (err error) {
os.Exit(1)
}
DB = db
if err = migrateProxyRouteEnableHTTPSColumn(db); err != nil {
if err = registerSharding(db, backend); err != nil {
return err
}
if err = autoMigrateAll(db); err != nil {
return err
}
if err = migrateTextColumns(db, backend); err != nil {
return err
}
if err = migrateSQLiteDataIfNeeded(db, backend); err != nil {
if err = ensureDatabaseSchemaUpToDate(db, backend); err != nil {
return err
}
return createRootAccountIfNeed()
+766 -4
View File
@@ -1,23 +1,131 @@
package model
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
type legacyProxyRouteV4 struct {
ID uint `gorm:"primaryKey"`
Domain string `gorm:"uniqueIndex;size:255;not null"`
OriginID *uint `gorm:"index"`
OriginURL string `gorm:"size:2048;not null"`
OriginHost string `gorm:"size:255"`
Upstreams string `gorm:"type:text;not null;default:'[]'"`
Enabled bool `gorm:"not null;default:true"`
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
CertID *uint
RedirectHTTP bool `gorm:"not null;default:false"`
CacheEnabled bool `gorm:"not null;default:false"`
CachePolicy string `gorm:"size:32;not null;default:''"`
CacheRules string `gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
Remark string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (legacyProxyRouteV4) TableName() string {
return "proxy_routes"
}
type legacyProxyRouteV5 struct {
ID uint `gorm:"primaryKey"`
SiteName string `gorm:"size:255;not null;default:''"`
Domain string `gorm:"uniqueIndex;size:255;not null"`
Domains string `gorm:"type:text;not null;default:'[]'"`
OriginID *uint `gorm:"index"`
OriginURL string `gorm:"size:2048;not null"`
OriginHost string `gorm:"size:255"`
Upstreams string `gorm:"type:text;not null;default:'[]'"`
Enabled bool `gorm:"not null;default:true"`
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
CertID *uint
RedirectHTTP bool `gorm:"not null;default:false"`
CacheEnabled bool `gorm:"not null;default:false"`
CachePolicy string `gorm:"size:32;not null;default:''"`
CacheRules string `gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
Remark string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (legacyProxyRouteV5) TableName() string {
return "proxy_routes"
}
type legacyProxyRouteV6 struct {
ID uint `gorm:"primaryKey"`
SiteName string `gorm:"size:255;not null;default:''"`
Domain string `gorm:"uniqueIndex;size:255;not null"`
Domains string `gorm:"type:text;not null;default:'[]'"`
OriginID *uint `gorm:"index"`
OriginURL string `gorm:"size:2048;not null"`
OriginHost string `gorm:"size:255"`
Upstreams string `gorm:"type:text;not null;default:'[]'"`
Enabled bool `gorm:"not null;default:true"`
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
CertID *uint
RedirectHTTP bool `gorm:"not null;default:false"`
LimitConnPerServer int `gorm:"not null;default:0"`
LimitConnPerIP int `gorm:"not null;default:0"`
LimitRate string `gorm:"size:32;not null;default:''"`
CacheEnabled bool `gorm:"not null;default:false"`
CachePolicy string `gorm:"size:32;not null;default:''"`
CacheRules string `gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
Remark string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (legacyProxyRouteV6) TableName() string {
return "proxy_routes"
}
type legacyProxyRouteV7 struct {
ID uint `gorm:"primaryKey"`
SiteName string `gorm:"size:255;not null;default:''"`
Domain string `gorm:"uniqueIndex;size:255;not null"`
Domains string `gorm:"type:text;not null;default:'[]'"`
OriginID *uint `gorm:"index"`
OriginURL string `gorm:"size:2048;not null"`
OriginHost string `gorm:"size:255"`
Upstreams string `gorm:"type:text;not null;default:'[]'"`
Enabled bool `gorm:"not null;default:true"`
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
CertID *uint
CertIDs string `gorm:"type:text;not null;default:'[]'"`
RedirectHTTP bool `gorm:"not null;default:false"`
LimitConnPerServer int `gorm:"not null;default:0"`
LimitConnPerIP int `gorm:"not null;default:0"`
LimitRate string `gorm:"size:32;not null;default:''"`
CacheEnabled bool `gorm:"not null;default:false"`
CachePolicy string `gorm:"size:32;not null;default:''"`
CacheRules string `gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
Remark string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (legacyProxyRouteV7) TableName() string {
return "proxy_routes"
}
func openBareTestSQLiteDB(t *testing.T, name string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite db: %v", err)
}
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sql db: %v", err)
@@ -28,6 +136,16 @@ func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
return db
}
func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
t.Helper()
db := openBareTestSQLiteDB(t, name)
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
return db
}
func findDBModelByTableName(t *testing.T, tableName string) dbModel {
t.Helper()
@@ -121,3 +239,647 @@ func TestMigrateTableDataCopiesRows(t *testing.T) {
t.Fatalf("unexpected migrated option value: %s", gotOption.Value)
}
}
func TestRegisterShardingAutoMigratesShardTables(t *testing.T) {
db := openBareTestSQLiteDB(t, "sharded.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
for _, table := range []string{
"node_metric_snapshots_00",
"node_metric_snapshots_09",
"node_request_reports_00",
"node_request_reports_09",
"node_access_logs_00",
"node_access_logs_09",
} {
if !db.Migrator().HasTable(table) {
t.Fatalf("expected sharded table %s to exist", table)
}
}
}
func TestMigrateObservabilityLegacyColumnsBackfillsHealthEventMetadata(t *testing.T) {
db := openTestSQLiteDB(t, "legacy-health-events.db")
if err := db.Exec("ALTER TABLE node_health_events ADD COLUMN raw_json TEXT").Error; err != nil {
t.Fatalf("add raw_json column: %v", err)
}
rawJSON, err := json.Marshal(map[string]any{
"event_type": "sync_error",
"metadata": map[string]string{
"reason": "checksum_mismatch",
"scope": "routes",
},
})
if err != nil {
t.Fatalf("marshal raw json: %v", err)
}
event := &NodeHealthEvent{
NodeID: "node-legacy",
EventType: "sync_error",
Severity: "warning",
Status: "active",
Message: "checksum mismatch",
FirstTriggeredAt: time.Now().Add(-time.Minute),
LastTriggeredAt: time.Now(),
ReportedAt: time.Now(),
}
if err := db.Create(event).Error; err != nil {
t.Fatalf("create health event: %v", err)
}
if err := db.Exec("UPDATE node_health_events SET raw_json = ? WHERE id = ?", string(rawJSON), event.ID).Error; err != nil {
t.Fatalf("seed legacy raw_json: %v", err)
}
if err := migrateObservabilityLegacyColumns(db); err != nil {
t.Fatalf("migrateObservabilityLegacyColumns: %v", err)
}
var got NodeHealthEvent
if err := db.First(&got, event.ID).Error; err != nil {
t.Fatalf("query health event: %v", err)
}
if got.MetadataJSON == "" {
t.Fatal("expected metadata_json to be backfilled")
}
}
func TestEnsureDatabaseSchemaUpToDateInitializesFreshDatabase(t *testing.T) {
db := openBareTestSQLiteDB(t, "fresh-schema.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
version, exists, err := loadDatabaseSchemaVersion(db)
if err != nil {
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
}
if !exists {
t.Fatal("expected database schema version to be recorded")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
}
}
func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-schema.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
if err := db.Create(&User{
Username: "legacy",
Password: "secret",
DisplayName: "Legacy User",
Role: 1,
Status: 1,
}).Error; err != nil {
t.Fatalf("seed legacy user: %v", err)
}
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
version, exists, err := loadDatabaseSchemaVersion(db)
if err != nil {
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
}
if !exists {
t.Fatal("expected legacy database to gain a schema version record")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
}
}
func TestEnsureDatabaseSchemaUpToDateMigratesObservabilityShardsToID(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-observability-shards.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
now := time.Now().UTC()
if err := db.Table("node_metric_snapshots_00").Create(&NodeMetricSnapshot{
ID: 1,
NodeID: "node-a",
CapturedAt: now.Add(-2 * time.Minute),
CPUUsagePercent: 22,
MemoryUsedBytes: 2,
MemoryTotalBytes: 8,
}).Error; err != nil {
t.Fatalf("seed metric snapshot shard 00: %v", err)
}
if err := db.Table("node_metric_snapshots_01").Create(&NodeMetricSnapshot{
ID: 1,
NodeID: "node-b",
CapturedAt: now.Add(-time.Minute),
CPUUsagePercent: 44,
MemoryUsedBytes: 4,
MemoryTotalBytes: 8,
}).Error; err != nil {
t.Fatalf("seed metric snapshot shard 01: %v", err)
}
if err := db.Table("node_request_reports_00").Create(&NodeRequestReport{
ID: 1,
NodeID: "node-a",
WindowStartedAt: now.Add(-3 * time.Minute),
WindowEndedAt: now.Add(-2 * time.Minute),
RequestCount: 12,
ErrorCount: 1,
UniqueVisitorCount: 6,
}).Error; err != nil {
t.Fatalf("seed request report shard 00: %v", err)
}
if err := db.Table("node_request_reports_01").Create(&NodeRequestReport{
ID: 1,
NodeID: "node-b",
WindowStartedAt: now.Add(-2 * time.Minute),
WindowEndedAt: now.Add(-time.Minute),
RequestCount: 21,
ErrorCount: 2,
UniqueVisitorCount: 9,
}).Error; err != nil {
t.Fatalf("seed request report shard 01: %v", err)
}
if err := db.Table("node_access_logs_00").Create(&NodeAccessLog{
ID: 1,
NodeID: "node-a",
LoggedAt: now.Add(-90 * time.Second),
RemoteAddr: "203.0.113.10",
Host: "a.example.com",
Path: "/alpha",
StatusCode: 200,
}).Error; err != nil {
t.Fatalf("seed access log shard 00: %v", err)
}
if err := db.Table("node_access_logs_01").Create(&NodeAccessLog{
ID: 1,
NodeID: "node-b",
LoggedAt: now.Add(-60 * time.Second),
RemoteAddr: "203.0.113.11",
Host: "b.example.com",
Path: "/beta",
StatusCode: 502,
}).Error; err != nil {
t.Fatalf("seed access log shard 01: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 2); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
version, exists, err := loadDatabaseSchemaVersion(db)
if err != nil {
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
}
if !exists {
t.Fatal("expected migrated database to keep schema version record")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
}
for _, baseTable := range shardedObservabilityBaseTables() {
for _, table := range observabilityShardTables(baseTable) {
legacyTable := legacyObservabilityShardTableName(table)
if db.Migrator().HasTable(legacyTable) {
t.Fatalf("expected legacy shard table %s to be removed", legacyTable)
}
}
}
snapshots, err := ListMetricSnapshotsSince(time.Time{})
if err != nil {
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
}
if len(snapshots) != 2 {
t.Fatalf("expected 2 migrated metric snapshots, got %+v", snapshots)
}
reports, err := ListRequestReportsSince(time.Time{})
if err != nil {
t.Fatalf("ListRequestReportsSince failed: %v", err)
}
if len(reports) != 2 {
t.Fatalf("expected 2 migrated request reports, got %+v", reports)
}
logs, err := ListNodeAccessLogs(NodeAccessLogQuery{Page: 0, PageSize: 10})
if err != nil {
t.Fatalf("ListNodeAccessLogs failed: %v", err)
}
if len(logs) != 2 {
t.Fatalf("expected 2 migrated access logs, got %+v", logs)
}
seenSnapshotIDs := make(map[uint]struct{}, len(snapshots))
for _, item := range snapshots {
if item == nil || item.ID == 0 {
t.Fatalf("expected migrated metric snapshot to have a new non-zero id: %+v", item)
}
if _, exists := seenSnapshotIDs[item.ID]; exists {
t.Fatalf("expected migrated metric snapshot ids to be unique, got duplicate %d", item.ID)
}
seenSnapshotIDs[item.ID] = struct{}{}
targetTable := observabilityShardTableForID("node_metric_snapshots", item.ID)
var count int64
if err := db.Table(targetTable).Where("id = ?", item.ID).Count(&count).Error; err != nil {
t.Fatalf("count migrated metric snapshot in target shard: %v", err)
}
if count != 1 {
t.Fatalf("expected migrated metric snapshot id %d to be stored in %s", item.ID, targetTable)
}
}
}
func TestMigrateOriginsSchemaBackfillsOrigins(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-origins.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := applyCurrentSchema(db, "sqlite"); err != nil {
t.Fatalf("applyCurrentSchema: %v", err)
}
now := time.Now().UTC()
route := &ProxyRoute{
Domain: "app.example.com",
OriginURL: "https://origin-a.internal:8443/api",
Upstreams: `["https://origin-a.internal:8443/api"]`,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(route).Error; err != nil {
t.Fatalf("seed proxy route: %v", err)
}
if err := db.Exec(`DELETE FROM origins`).Error; err != nil {
t.Fatalf("clear origins: %v", err)
}
if err := db.Model(&ProxyRoute{}).Where("id = ?", route.ID).Update("origin_id", nil).Error; err != nil {
t.Fatalf("clear route origin_id: %v", err)
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
t.Fatalf("backfillOriginsFromProxyRoutes: %v", err)
}
if !db.Migrator().HasTable(&Origin{}) {
t.Fatal("expected origins table to exist")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") {
t.Fatal("expected proxy_routes.origin_id column to exist")
}
reloadedRoute := &ProxyRoute{}
if err := db.First(reloadedRoute, route.ID).Error; err != nil {
t.Fatalf("query proxy route: %v", err)
}
if reloadedRoute.OriginID == nil || *reloadedRoute.OriginID == 0 {
t.Fatal("expected migrated route to be linked to a backfilled origin")
}
origin := &Origin{}
if err := db.First(origin, *reloadedRoute.OriginID).Error; err != nil {
t.Fatalf("query origin: %v", err)
}
if origin.Address != "origin-a.internal" {
t.Fatalf("unexpected backfilled origin address: %s", origin.Address)
}
}
func TestEnsureDatabaseSchemaUpToDateBackfillsProxyRouteSiteFields(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-proxy-route-sites.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
for _, item := range registeredModels() {
if _, ok := item.(*ProxyRoute); ok {
continue
}
if err := db.AutoMigrate(item); err != nil {
t.Fatalf("auto migrate supporting table: %v", err)
}
}
if err := db.AutoMigrate(&legacyProxyRouteV4{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes: %v", err)
}
now := time.Now().UTC()
if err := db.Create(&legacyProxyRouteV4{
Domain: "app.example.com",
OriginURL: "https://origin-a.internal:8443",
Upstreams: `["https://origin-a.internal:8443","https://origin-b.internal:8443"]`,
Enabled: true,
EnableHTTPS: false,
RedirectHTTP: false,
CacheEnabled: false,
CachePolicy: "",
CacheRules: `[]`,
CustomHeaders: `[]`,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("seed legacy proxy route: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 4); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
var route ProxyRoute
if err := db.First(&route).Error; err != nil {
t.Fatalf("query migrated proxy route: %v", err)
}
if route.SiteName != "app.example.com" {
t.Fatalf("unexpected site_name after migration: %s", route.SiteName)
}
if route.Domain != "app.example.com" {
t.Fatalf("unexpected domain mirror after migration: %s", route.Domain)
}
var domains []string
if err := json.Unmarshal([]byte(route.Domains), &domains); err != nil {
t.Fatalf("decode migrated domains: %v", err)
}
if len(domains) != 1 || domains[0] != "app.example.com" {
t.Fatalf("unexpected migrated domains: %#v", domains)
}
}
func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteRateLimitFields(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-proxy-route-rate-limits.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
for _, item := range registeredModels() {
if _, ok := item.(*ProxyRoute); ok {
continue
}
if err := db.AutoMigrate(item); err != nil {
t.Fatalf("auto migrate supporting table: %v", err)
}
}
if err := db.AutoMigrate(&legacyProxyRouteV5{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes v5: %v", err)
}
now := time.Now().UTC()
if err := db.Create(&legacyProxyRouteV5{
SiteName: "main-site",
Domain: "app.example.com",
Domains: `["app.example.com","www.example.com"]`,
OriginURL: "https://origin-a.internal:8443",
Upstreams: `["https://origin-a.internal:8443"]`,
Enabled: true,
EnableHTTPS: false,
RedirectHTTP: false,
CacheEnabled: false,
CachePolicy: "",
CacheRules: `[]`,
CustomHeaders: `[]`,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("seed legacy proxy route v5: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 5); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
var route ProxyRoute
if err := db.First(&route).Error; err != nil {
t.Fatalf("query migrated proxy route: %v", err)
}
if route.LimitConnPerServer != 0 || route.LimitConnPerIP != 0 || route.LimitRate != "" {
t.Fatalf("expected new rate limit fields to default to disabled values, got %+v", route)
}
}
func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteCertificateListFields(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-proxy-route-cert-ids.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
for _, item := range registeredModels() {
if _, ok := item.(*ProxyRoute); ok {
continue
}
if err := db.AutoMigrate(item); err != nil {
t.Fatalf("auto migrate supporting table: %v", err)
}
}
if err := db.AutoMigrate(&legacyProxyRouteV6{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes v6: %v", err)
}
now := time.Now().UTC()
certID := uint(9)
if err := db.Create(&legacyProxyRouteV6{
SiteName: "secure-site",
Domain: "secure.example.com",
Domains: `["secure.example.com","www.secure.example.com"]`,
OriginURL: "https://origin-secure.internal:8443",
Upstreams: `["https://origin-secure.internal:8443"]`,
Enabled: true,
EnableHTTPS: true,
CertID: &certID,
RedirectHTTP: true,
LimitConnPerServer: 120,
LimitConnPerIP: 12,
LimitRate: "512k",
CacheEnabled: false,
CachePolicy: "",
CacheRules: `[]`,
CustomHeaders: `[]`,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("seed legacy proxy route v6: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 6); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
var route ProxyRoute
if err := db.First(&route).Error; err != nil {
t.Fatalf("query migrated proxy route: %v", err)
}
if route.CertID == nil || *route.CertID != certID {
t.Fatalf("expected cert_id mirror to be preserved, got %+v", route.CertID)
}
var certIDs []uint
if err := json.Unmarshal([]byte(route.CertIDs), &certIDs); err != nil {
t.Fatalf("decode migrated cert_ids: %v", err)
}
if len(certIDs) != 1 || certIDs[0] != certID {
t.Fatalf("unexpected migrated cert_ids: %#v", certIDs)
}
}
func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteDomainCertificateFields(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-proxy-route-domain-cert-ids.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
for _, item := range registeredModels() {
if _, ok := item.(*ProxyRoute); ok {
continue
}
if err := db.AutoMigrate(item); err != nil {
t.Fatalf("auto migrate supporting table: %v", err)
}
}
if err := db.AutoMigrate(&legacyProxyRouteV7{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes v7: %v", err)
}
now := time.Now().UTC()
certID := uint(9)
if err := db.Create(&legacyProxyRouteV7{
SiteName: "secure-site",
Domain: "secure.example.com",
Domains: `["secure.example.com","www.secure.example.com"]`,
OriginURL: "https://origin-secure.internal:8443",
Upstreams: `["https://origin-secure.internal:8443"]`,
Enabled: true,
EnableHTTPS: true,
CertID: &certID,
CertIDs: `[9]`,
RedirectHTTP: true,
LimitConnPerServer: 120,
LimitConnPerIP: 12,
LimitRate: "512k",
CacheEnabled: false,
CachePolicy: "",
CacheRules: `[]`,
CustomHeaders: `[]`,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("seed legacy proxy route v7: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 7); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
var route ProxyRoute
if err := db.First(&route).Error; err != nil {
t.Fatalf("query migrated proxy route: %v", err)
}
var domainCertIDs []uint
if err := json.Unmarshal([]byte(route.DomainCertIDs), &domainCertIDs); err != nil {
t.Fatalf("decode migrated domain_cert_ids: %v", err)
}
if len(domainCertIDs) != 2 || domainCertIDs[0] != certID || domainCertIDs[1] != certID {
t.Fatalf("unexpected migrated domain_cert_ids: %#v", domainCertIDs)
}
}
func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *testing.T) {
db := openBareTestSQLiteDB(t, "failed-validation.db")
err := runDatabaseSchemaMigration(db, "sqlite", databaseSchemaMigration{
fromVersion: legacyDatabaseSchemaVersion,
toVersion: currentDatabaseSchemaVersion,
migrate: func(tx *gorm.DB, backend string) error {
return autoMigrateSchemaMetadata(tx)
},
validate: func(tx *gorm.DB, backend string) error {
return gorm.ErrInvalidDB
},
})
if err == nil {
t.Fatal("expected migration validation to fail")
}
_, exists, loadErr := loadDatabaseSchemaVersion(db)
if loadErr != nil {
t.Fatalf("loadDatabaseSchemaVersion: %v", loadErr)
}
if exists {
t.Fatal("expected schema version to remain unset after failed validation")
}
}
File diff suppressed because it is too large Load Diff
+447 -106
View File
@@ -2,6 +2,7 @@ package model
import (
"fmt"
"sort"
"strings"
"time"
@@ -10,14 +11,13 @@ import (
type NodeAccessLog struct {
ID uint `json:"id" gorm:"primaryKey"`
NodeID string `json:"node_id" gorm:"index:idx_node_access_logs_node_logged_at,priority:1;size:64;not null"`
LoggedAt time.Time `json:"logged_at" gorm:"index:idx_node_access_logs_logged_at;index:idx_node_access_logs_node_logged_at,priority:2"`
RemoteAddr string `json:"remote_addr" gorm:"index:idx_node_access_logs_remote_addr;size:128"`
NodeID string `json:"node_id" gorm:"index:,composite:node_logged_at,priority:1;size:64;not null"`
LoggedAt time.Time `json:"logged_at" gorm:"index;index:,composite:node_logged_at,priority:2"`
RemoteAddr string `json:"remote_addr" gorm:"index;size:128"`
Region string `json:"region" gorm:"size:128"`
Host string `json:"host" gorm:"index:idx_node_access_logs_host;size:255"`
Host string `json:"host" gorm:"index;size:255"`
Path string `json:"path" gorm:"size:2048"`
StatusCode int `json:"status_code" gorm:"index:idx_node_access_logs_status_code"`
RawJSON string `json:"raw_json" gorm:"type:text"`
StatusCode int `json:"status_code" gorm:"index"`
CreatedAt time.Time `json:"created_at"`
}
@@ -92,85 +92,92 @@ type NodeAccessLogTrendPointRow struct {
RequestCount int64 `json:"request_count"`
}
func (log *NodeAccessLog) BeforeCreate(tx *gorm.DB) error {
return assignObservabilityID(&log.ID)
}
func ListNodeAccessLogs(query NodeAccessLogQuery) (logs []*NodeAccessLog, err error) {
offset := query.Page * query.PageSize
db := buildNodeAccessLogQuery(DB, query).
Order(buildNodeAccessLogSortClause(query.SortBy, query.SortOrder)).
Limit(query.PageSize).
Offset(offset)
err = db.Find(&logs).Error
return logs, err
all, err := listNodeAccessLogsAcrossShards(query)
if err != nil {
return nil, err
}
start, end := paginateBounds(len(all), query.Page, query.PageSize)
if start >= len(all) {
return []*NodeAccessLog{}, nil
}
return all[start:end], nil
}
func CountNodeAccessLogs(query NodeAccessLogQuery) (totalRecords int64, totalIPs int64, err error) {
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query)
if err = base.Count(&totalRecords).Error; err != nil {
all, err := listNodeAccessLogsAcrossShards(query)
if err != nil {
return 0, 0, err
}
distinctQuery := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query).
Where("remote_addr <> ''").
Distinct("remote_addr")
if err = distinctQuery.Count(&totalIPs).Error; err != nil {
return 0, 0, err
ips := make(map[string]struct{}, len(all))
for _, item := range all {
if item == nil {
continue
}
trimmed := strings.TrimSpace(item.RemoteAddr)
if trimmed != "" {
ips[trimmed] = struct{}{}
}
}
return totalRecords, totalIPs, nil
return int64(len(all)), int64(len(ips)), nil
}
func ListNodeAccessLogRegionCounts(nodeID string, since time.Time, limit int) (items []*NodeAccessLogRegionCount, err error) {
query := DB.Model(&NodeAccessLog{}).
Select("region as region, count(*) as count").
Where("region <> ''")
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
NodeID: nodeID,
Since: since,
})
if err != nil {
return nil, err
}
if !since.IsZero() {
query = query.Where("logged_at >= ?", since)
counts := make(map[string]int64)
for _, item := range logs {
if item == nil {
continue
}
region := strings.TrimSpace(item.Region)
if region == "" {
continue
}
counts[region]++
}
query = query.Group("region").Order("count desc, region asc")
if limit > 0 {
query = query.Limit(limit)
items = make([]*NodeAccessLogRegionCount, 0, len(counts))
for region, count := range counts {
items = append(items, &NodeAccessLogRegionCount{
Region: region,
Count: count,
})
}
err = query.Scan(&items).Error
return items, err
sort.Slice(items, func(i int, j int) bool {
if items[i].Count == items[j].Count {
return items[i].Region < items[j].Region
}
return items[i].Count > items[j].Count
})
if limit > 0 && len(items) > limit {
items = items[:limit]
}
return items, nil
}
func ListNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (items []*NodeAccessLogBucketRow, err error) {
offset := query.Page * query.PageSize
bucketExpr := accessLogBucketEpochExpr(query.FoldMinutes)
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Path: query.Path,
Since: query.Since,
})
err = base.Select(fmt.Sprintf(
"%s as bucket_epoch, count(*) as request_count, count(distinct remote_addr) as unique_ip_count, count(distinct host) as unique_host_count, sum(case when status_code < 400 then 1 else 0 end) as success_count, sum(case when status_code >= 400 and status_code < 500 then 1 else 0 end) as client_error_count, sum(case when status_code >= 500 then 1 else 0 end) as server_error_count",
bucketExpr,
)).
Group(bucketExpr).
Order(buildNodeAccessLogBucketSortClause(query.SortBy, query.SortOrder)).
Limit(query.PageSize).
Offset(offset).
Scan(&items).Error
return items, err
rows, err := buildNodeAccessLogBucketRows(query)
if err != nil {
return nil, err
}
start, end := paginateBounds(len(rows), query.Page, query.PageSize)
if start >= len(rows) {
return []*NodeAccessLogBucketRow{}, nil
}
return rows[start:end], nil
}
func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err error) {
bucketExpr := accessLogBucketEpochExpr(query.FoldMinutes)
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Path: query.Path,
Since: query.Since,
})
rows := []struct {
BucketEpoch int64 `gorm:"column:bucket_epoch"`
}{}
err = base.Select(fmt.Sprintf("%s as bucket_epoch", bucketExpr)).
Group(bucketExpr).
Scan(&rows).Error
rows, err := buildNodeAccessLogBucketRows(query)
if err != nil {
return 0, err
}
@@ -178,39 +185,19 @@ func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err
}
func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) {
offset := query.Page * query.PageSize
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
}).Where("remote_addr <> ''")
lastSeenExpr := accessLogEpochExpr("max(logged_at)")
err = base.Select(
"remote_addr as remote_addr, count(*) as total_requests, sum(case when logged_at >= ? then 1 else 0 end) as recent_requests, "+lastSeenExpr+" as last_seen_epoch",
recentSince,
).
Group("remote_addr").
Order(buildNodeAccessLogIPSummarySortClause(query.SortBy, query.SortOrder)).
Limit(query.PageSize).
Offset(offset).
Scan(&items).Error
return items, err
rows, err := buildNodeAccessLogIPSummaryRows(query, recentSince)
if err != nil {
return nil, err
}
start, end := paginateBounds(len(rows), query.Page, query.PageSize)
if start >= len(rows) {
return []*NodeAccessLogIPSummaryRow{}, nil
}
return rows[start:end], nil
}
func CountNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery) (total int64, err error) {
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
}).Where("remote_addr <> ''")
rows := []struct {
RemoteAddr string `gorm:"column:remote_addr"`
}{}
err = base.Select("remote_addr").
Group("remote_addr").
Scan(&rows).Error
rows, err := buildNodeAccessLogIPSummaryRows(query, time.Time{})
if err != nil {
return 0, err
}
@@ -218,23 +205,82 @@ func CountNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery) (total int
}
func ListNodeAccessLogIPTrend(query NodeAccessLogIPTrendQuery) (items []*NodeAccessLogTrendPointRow, err error) {
bucketExpr := accessLogBucketEpochExpr(query.BucketMinutes)
base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
}).Where("remote_addr = ?", strings.TrimSpace(query.RemoteAddr))
err = base.Select(fmt.Sprintf("%s as bucket_epoch, count(*) as request_count", bucketExpr)).
Group(bucketExpr).
Order("bucket_epoch asc").
Scan(&items).Error
return items, err
})
if err != nil {
return nil, err
}
remoteAddr := strings.TrimSpace(query.RemoteAddr)
if remoteAddr == "" {
return []*NodeAccessLogTrendPointRow{}, nil
}
buckets := make(map[int64]int64)
for _, item := range logs {
if item == nil || strings.TrimSpace(item.RemoteAddr) != remoteAddr {
continue
}
bucketEpoch := bucketEpochForTime(item.LoggedAt, query.BucketMinutes)
buckets[bucketEpoch]++
}
items = make([]*NodeAccessLogTrendPointRow, 0, len(buckets))
for bucketEpoch, requestCount := range buckets {
items = append(items, &NodeAccessLogTrendPointRow{
BucketEpoch: bucketEpoch,
RequestCount: requestCount,
})
}
sort.Slice(items, func(i int, j int) bool {
return items[i].BucketEpoch < items[j].BucketEpoch
})
return items, nil
}
func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) {
result := DB.Where("logged_at < ?", before).Delete(&NodeAccessLog{})
return result.RowsAffected, result.Error
return deleteAcrossShards(DB, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB {
return tx.Where("logged_at < ?", before)
})
}
func DeleteAllNodeAccessLogs(db *gorm.DB) (deleted int64, err error) {
return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, nil)
}
func NodeAccessLogExists(db *gorm.DB, record *NodeAccessLog) (bool, error) {
if record == nil {
return false, nil
}
db = normalizeShardedDB(db)
for _, table := range observabilityShardTables("node_access_logs") {
var count int64
if err := db.Table(table).
Where(
"node_id = ? AND logged_at = ? AND remote_addr = ? AND host = ? AND path = ? AND status_code = ?",
record.NodeID,
record.LoggedAt,
record.RemoteAddr,
record.Host,
record.Path,
record.StatusCode,
).
Limit(1).
Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func DeleteNodeAccessLogsByNodeBefore(db *gorm.DB, nodeID string, before time.Time) (deleted int64, err error) {
return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB {
return tx.Where("node_id = ? AND logged_at < ?", nodeID, before)
})
}
func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
@@ -244,6 +290,10 @@ func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
if db.Statement == nil || db.Statement.Model == nil {
db = db.Model(&NodeAccessLog{})
}
return applyNodeAccessLogFilters(db, query)
}
func applyNodeAccessLogFilters(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
if trimmed := strings.TrimSpace(query.NodeID); trimmed != "" {
db = db.Where("node_id LIKE ?", "%"+trimmed+"%")
}
@@ -262,6 +312,297 @@ func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
return db
}
func listNodeAccessLogsAcrossShards(query NodeAccessLogQuery) ([]*NodeAccessLog, error) {
items, err := queryAcrossShards("node_access_logs", func(tx *gorm.DB) ([]*NodeAccessLog, error) {
var shardRows []*NodeAccessLog
if err := applyNodeAccessLogFilters(tx, query).Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
sortNodeAccessLogs(items, query.SortBy, query.SortOrder)
return items, nil
}
func buildNodeAccessLogBucketRows(query NodeAccessLogBucketQuery) ([]*NodeAccessLogBucketRow, error) {
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Path: query.Path,
Since: query.Since,
})
if err != nil {
return nil, err
}
type bucketAccumulator struct {
requestCount int64
uniqueIPs map[string]struct{}
uniqueHosts map[string]struct{}
successCount int64
clientErrorCount int64
serverErrorCount int64
}
accumulators := make(map[int64]*bucketAccumulator)
for _, item := range logs {
if item == nil {
continue
}
bucketEpoch := bucketEpochForTime(item.LoggedAt, query.FoldMinutes)
accumulator := accumulators[bucketEpoch]
if accumulator == nil {
accumulator = &bucketAccumulator{
uniqueIPs: make(map[string]struct{}),
uniqueHosts: make(map[string]struct{}),
}
accumulators[bucketEpoch] = accumulator
}
accumulator.requestCount++
if trimmed := strings.TrimSpace(item.RemoteAddr); trimmed != "" {
accumulator.uniqueIPs[trimmed] = struct{}{}
}
if trimmed := strings.TrimSpace(item.Host); trimmed != "" {
accumulator.uniqueHosts[trimmed] = struct{}{}
}
switch {
case item.StatusCode < 400:
accumulator.successCount++
case item.StatusCode < 500:
accumulator.clientErrorCount++
default:
accumulator.serverErrorCount++
}
}
rows := make([]*NodeAccessLogBucketRow, 0, len(accumulators))
for bucketEpoch, accumulator := range accumulators {
rows = append(rows, &NodeAccessLogBucketRow{
BucketEpoch: bucketEpoch,
RequestCount: accumulator.requestCount,
UniqueIPCount: int64(len(accumulator.uniqueIPs)),
UniqueHostCount: int64(len(accumulator.uniqueHosts)),
SuccessCount: accumulator.successCount,
ClientErrorCount: accumulator.clientErrorCount,
ServerErrorCount: accumulator.serverErrorCount,
})
}
sortNodeAccessLogBucketRows(rows, query.SortBy, query.SortOrder)
return rows, nil
}
func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSince time.Time) ([]*NodeAccessLogIPSummaryRow, error) {
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
NodeID: query.NodeID,
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
})
if err != nil {
return nil, err
}
type accumulator struct {
totalRequests int64
recentRequests int64
lastSeenAt time.Time
}
accumulators := make(map[string]*accumulator)
for _, item := range logs {
if item == nil {
continue
}
remoteAddr := strings.TrimSpace(item.RemoteAddr)
if remoteAddr == "" {
continue
}
acc := accumulators[remoteAddr]
if acc == nil {
acc = &accumulator{}
accumulators[remoteAddr] = acc
}
acc.totalRequests++
if !recentSince.IsZero() && !item.LoggedAt.Before(recentSince) {
acc.recentRequests++
}
if item.LoggedAt.After(acc.lastSeenAt) {
acc.lastSeenAt = item.LoggedAt
}
}
rows := make([]*NodeAccessLogIPSummaryRow, 0, len(accumulators))
for remoteAddr, acc := range accumulators {
rows = append(rows, &NodeAccessLogIPSummaryRow{
RemoteAddr: remoteAddr,
TotalRequests: acc.totalRequests,
RecentRequests: acc.recentRequests,
LastSeenEpoch: acc.lastSeenAt.Unix(),
})
}
sortNodeAccessLogIPSummaryRows(rows, query.SortBy, query.SortOrder)
return rows, nil
}
func sortNodeAccessLogs(items []*NodeAccessLog, sortBy string, sortOrder string) {
desc := normalizeSortOrder(sortOrder) != "asc"
sort.Slice(items, func(i int, j int) bool {
left := items[i]
right := items[j]
if left == nil || right == nil {
return left != nil
}
var compare int
switch strings.TrimSpace(sortBy) {
case "status_code":
compare = compareInt(left.StatusCode, right.StatusCode)
case "remote_addr":
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
case "host":
compare = strings.Compare(left.Host, right.Host)
case "path":
compare = strings.Compare(left.Path, right.Path)
default:
compare = compareTime(left.LoggedAt, right.LoggedAt)
}
if compare == 0 {
compare = compareTime(left.LoggedAt, right.LoggedAt)
}
if compare == 0 {
compare = compareUint(left.ID, right.ID)
}
if desc {
return compare > 0
}
return compare < 0
})
}
func sortNodeAccessLogBucketRows(items []*NodeAccessLogBucketRow, sortBy string, sortOrder string) {
desc := normalizeSortOrder(sortOrder) != "asc"
sort.Slice(items, func(i int, j int) bool {
left := items[i]
right := items[j]
if left == nil || right == nil {
return left != nil
}
var compare int
switch strings.TrimSpace(sortBy) {
case "request_count":
compare = compareInt64(left.RequestCount, right.RequestCount)
default:
compare = compareInt64(left.BucketEpoch, right.BucketEpoch)
}
if compare == 0 {
compare = compareInt64(left.BucketEpoch, right.BucketEpoch)
}
if desc {
return compare > 0
}
return compare < 0
})
}
func sortNodeAccessLogIPSummaryRows(items []*NodeAccessLogIPSummaryRow, sortBy string, sortOrder string) {
desc := normalizeSortOrder(sortOrder) != "asc"
sort.Slice(items, func(i int, j int) bool {
left := items[i]
right := items[j]
if left == nil || right == nil {
return left != nil
}
var compare int
switch strings.TrimSpace(sortBy) {
case "recent_requests":
compare = compareInt64(left.RecentRequests, right.RecentRequests)
case "last_seen_at":
compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch)
case "remote_addr":
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
default:
compare = compareInt64(left.TotalRequests, right.TotalRequests)
}
if compare == 0 {
compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch)
}
if compare == 0 {
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
}
if desc {
return compare > 0
}
return compare < 0
})
}
func paginateBounds(total int, page int, pageSize int) (int, int) {
if page < 0 {
page = 0
}
if pageSize <= 0 {
return 0, total
}
start := page * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
return start, end
}
func bucketEpochForTime(value time.Time, bucketMinutes int) int64 {
bucketSeconds := int64(bucketMinutes * 60)
if bucketSeconds <= 0 {
bucketSeconds = 180
}
return (value.UTC().Unix() / bucketSeconds) * bucketSeconds
}
func compareTime(left time.Time, right time.Time) int {
switch {
case left.After(right):
return 1
case left.Before(right):
return -1
default:
return 0
}
}
func compareInt(left int, right int) int {
switch {
case left > right:
return 1
case left < right:
return -1
default:
return 0
}
}
func compareInt64(left int64, right int64) int {
switch {
case left > right:
return 1
case left < right:
return -1
default:
return 0
}
}
func compareUint(left uint, right uint) int {
switch {
case left > right:
return 1
case left < right:
return -1
default:
return 0
}
}
func buildNodeAccessLogSortClause(sortBy string, sortOrder string) string {
column := "logged_at"
switch strings.TrimSpace(sortBy) {
+1 -1
View File
@@ -13,7 +13,7 @@ type NodeHealthEvent struct {
LastTriggeredAt time.Time `json:"last_triggered_at" gorm:"index"`
ReportedAt time.Time `json:"reported_at" gorm:"index"`
ResolvedAt *time.Time `json:"resolved_at" gorm:"index"`
RawJSON string `json:"raw_json" gorm:"type:text"`
MetadataJSON string `json:"metadata_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+82 -14
View File
@@ -1,6 +1,11 @@
package model
import "time"
import (
"sort"
"time"
"gorm.io/gorm"
)
type NodeMetricSnapshot struct {
ID uint `json:"id" gorm:"primaryKey"`
@@ -18,31 +23,94 @@ type NodeMetricSnapshot struct {
OpenrestyRxBytes int64 `json:"openresty_rx_bytes"`
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
OpenrestyConnections int64 `json:"openresty_connections"`
RawJSON string `json:"raw_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
}
func (snapshot *NodeMetricSnapshot) BeforeCreate(tx *gorm.DB) error {
return assignObservabilityID(&snapshot.ID)
}
func (snapshot *NodeMetricSnapshot) Insert() error {
return DB.Create(snapshot).Error
}
func ListNodeMetricSnapshots(nodeID string, since time.Time, limit int) (snapshots []*NodeMetricSnapshot, err error) {
query := DB.Where("node_id = ?", nodeID).Order("captured_at desc")
if !since.IsZero() {
query = query.Where("captured_at >= ?", since)
rows, err := queryAcrossShards("node_metric_snapshots", func(tx *gorm.DB) ([]*NodeMetricSnapshot, error) {
var shardRows []*NodeMetricSnapshot
query := tx.Order("captured_at desc, id desc")
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
}
if !since.IsZero() {
query = query.Where("captured_at >= ?", since)
}
if err := query.Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
if limit > 0 {
query = query.Limit(limit)
sort.Slice(rows, func(i int, j int) bool {
if rows[i].CapturedAt.Equal(rows[j].CapturedAt) {
return rows[i].ID > rows[j].ID
}
return rows[i].CapturedAt.After(rows[j].CapturedAt)
})
if limit > 0 && len(rows) > limit {
rows = rows[:limit]
}
err = query.Find(&snapshots).Error
return snapshots, err
return rows, nil
}
func ListMetricSnapshotsSince(since time.Time) (snapshots []*NodeMetricSnapshot, err error) {
query := DB.Order("captured_at desc")
if !since.IsZero() {
query = query.Where("captured_at >= ?", since)
rows, err := queryAcrossShards("node_metric_snapshots", func(tx *gorm.DB) ([]*NodeMetricSnapshot, error) {
var shardRows []*NodeMetricSnapshot
query := tx.Order("captured_at desc")
if !since.IsZero() {
query = query.Where("captured_at >= ?", since)
}
if err := query.Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
err = query.Find(&snapshots).Error
return snapshots, err
sort.Slice(rows, func(i int, j int) bool {
if rows[i].CapturedAt.Equal(rows[j].CapturedAt) {
return rows[i].ID > rows[j].ID
}
return rows[i].CapturedAt.After(rows[j].CapturedAt)
})
return rows, nil
}
func NodeMetricSnapshotExists(db *gorm.DB, nodeID string, capturedAt time.Time) (bool, error) {
db = normalizeShardedDB(db)
for _, table := range observabilityShardTables("node_metric_snapshots") {
var count int64
if err := db.Table(table).
Where("node_id = ? AND captured_at = ?", nodeID, capturedAt).
Limit(1).
Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func DeleteNodeMetricSnapshotsBefore(db *gorm.DB, before time.Time) (int64, error) {
return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, func(tx *gorm.DB) *gorm.DB {
return tx.Where("captured_at < ?", before)
})
}
func DeleteAllNodeMetricSnapshots(db *gorm.DB) (int64, error) {
return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, nil)
}
+82 -14
View File
@@ -1,6 +1,11 @@
package model
import "time"
import (
"sort"
"time"
"gorm.io/gorm"
)
type NodeRequestReport struct {
ID uint `json:"id" gorm:"primaryKey"`
@@ -13,31 +18,94 @@ type NodeRequestReport struct {
StatusCodesJSON string `json:"status_codes_json" gorm:"type:text"`
TopDomainsJSON string `json:"top_domains_json" gorm:"type:text"`
SourceCountriesJSON string `json:"source_countries_json" gorm:"type:text"`
RawJSON string `json:"raw_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
}
func (report *NodeRequestReport) BeforeCreate(tx *gorm.DB) error {
return assignObservabilityID(&report.ID)
}
func (report *NodeRequestReport) Insert() error {
return DB.Create(report).Error
}
func ListNodeRequestReports(nodeID string, since time.Time, limit int) (reports []*NodeRequestReport, err error) {
query := DB.Where("node_id = ?", nodeID).Order("window_ended_at desc")
if !since.IsZero() {
query = query.Where("window_ended_at >= ?", since)
rows, err := queryAcrossShards("node_request_reports", func(tx *gorm.DB) ([]*NodeRequestReport, error) {
var shardRows []*NodeRequestReport
query := tx.Order("window_ended_at desc, id desc")
if nodeID != "" {
query = query.Where("node_id = ?", nodeID)
}
if !since.IsZero() {
query = query.Where("window_ended_at >= ?", since)
}
if err := query.Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
if limit > 0 {
query = query.Limit(limit)
sort.Slice(rows, func(i int, j int) bool {
if rows[i].WindowEndedAt.Equal(rows[j].WindowEndedAt) {
return rows[i].ID > rows[j].ID
}
return rows[i].WindowEndedAt.After(rows[j].WindowEndedAt)
})
if limit > 0 && len(rows) > limit {
rows = rows[:limit]
}
err = query.Find(&reports).Error
return reports, err
return rows, nil
}
func ListRequestReportsSince(since time.Time) (reports []*NodeRequestReport, err error) {
query := DB.Order("window_ended_at desc")
if !since.IsZero() {
query = query.Where("window_ended_at >= ?", since)
rows, err := queryAcrossShards("node_request_reports", func(tx *gorm.DB) ([]*NodeRequestReport, error) {
var shardRows []*NodeRequestReport
query := tx.Order("window_ended_at desc")
if !since.IsZero() {
query = query.Where("window_ended_at >= ?", since)
}
if err := query.Find(&shardRows).Error; err != nil {
return nil, err
}
return shardRows, nil
})
if err != nil {
return nil, err
}
err = query.Find(&reports).Error
return reports, err
sort.Slice(rows, func(i int, j int) bool {
if rows[i].WindowEndedAt.Equal(rows[j].WindowEndedAt) {
return rows[i].ID > rows[j].ID
}
return rows[i].WindowEndedAt.After(rows[j].WindowEndedAt)
})
return rows, nil
}
func NodeRequestReportExists(db *gorm.DB, nodeID string, windowStartedAt time.Time, windowEndedAt time.Time) (bool, error) {
db = normalizeShardedDB(db)
for _, table := range observabilityShardTables("node_request_reports") {
var count int64
if err := db.Table(table).
Where("node_id = ? AND window_started_at = ? AND window_ended_at = ?", nodeID, windowStartedAt, windowEndedAt).
Limit(1).
Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func DeleteNodeRequestReportsBefore(db *gorm.DB, before time.Time) (int64, error) {
return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, func(tx *gorm.DB) *gorm.DB {
return tx.Where("window_ended_at < ?", before)
})
}
func DeleteAllNodeRequestReports(db *gorm.DB) (int64, error) {
return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, nil)
}
@@ -20,7 +20,6 @@ type NodeSystemProfile struct {
TotalDiskBytes int64 `json:"total_disk_bytes"`
UptimeSeconds int64 `json:"uptime_seconds"`
ReportedAt time.Time `json:"reported_at" gorm:"index"`
RawJSON string `json:"raw_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -49,7 +48,6 @@ func UpsertNodeSystemProfile(profile *NodeSystemProfile) error {
"total_disk_bytes",
"uptime_seconds",
"reported_at",
"raw_json",
"updated_at",
}),
}).Create(profile).Error
+41 -12
View File
@@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
type Option struct {
@@ -56,6 +58,8 @@ func InitOptionMap() {
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
common.OptionMap["GeoIPProvider"] = common.GeoIPProvider
common.OptionMap["DatabaseAutoCleanupEnabled"] = strconv.FormatBool(common.DatabaseAutoCleanupEnabled)
common.OptionMap["DatabaseAutoCleanupRetentionDays"] = strconv.Itoa(common.DatabaseAutoCleanupRetentionDays)
common.OptionMap["OpenRestyWorkerProcesses"] = common.OpenRestyWorkerProcesses
common.OptionMap["OpenRestyWorkerConnections"] = strconv.Itoa(common.OpenRestyWorkerConnections)
common.OptionMap["OpenRestyWorkerRlimitNofile"] = strconv.Itoa(common.OpenRestyWorkerRlimitNofile)
@@ -108,19 +112,38 @@ func InitOptionMap() {
}
func UpdateOption(key string, value string) error {
// Save to database first
option := Option{
Key: key,
return UpdateOptions([]Option{{
Key: key,
Value: value,
}})
}
func UpdateOptions(options []Option) error {
if len(options) == 0 {
return nil
}
if err := DB.Transaction(func(tx *gorm.DB) error {
for _, item := range options {
option := Option{
Key: item.Key,
}
if err := tx.FirstOrCreate(&option, Option{Key: item.Key}).Error; err != nil {
return err
}
option.Value = item.Value
if err := tx.Save(&option).Error; err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
for _, item := range options {
updateOptionMap(item.Key, item.Value)
}
// https://gorm.io/docs/update.html#Save-All-Fields
DB.FirstOrCreate(&option, Option{Key: key})
option.Value = value
// Save is a combination function.
// If save value does not contain primary key, it will execute Create,
// otherwise it will execute Update (with all fields).
DB.Save(&option)
// Update OptionMap
updateOptionMap(key, value)
return nil
}
@@ -219,6 +242,12 @@ func updateOptionMap(key string, value string) {
common.GeoIPProvider = value
shouldRefreshGeoIP = true
}
case "DatabaseAutoCleanupEnabled":
common.DatabaseAutoCleanupEnabled = value == "true"
case "DatabaseAutoCleanupRetentionDays":
if v, err := strconv.Atoi(value); err == nil && v >= 1 {
common.DatabaseAutoCleanupRetentionDays = v
}
case "OpenRestyWorkerProcesses":
if strings.TrimSpace(value) != "" {
common.OpenRestyWorkerProcesses = value
+30
View File
@@ -0,0 +1,30 @@
package model
import (
"openflare/common"
"testing"
)
func TestInitOptionMapDefaultsRegisterDisabled(t *testing.T) {
previousRegisterEnabled := common.RegisterEnabled
previousOptionMap := common.OptionMap
previousDB := DB
t.Cleanup(func() {
common.RegisterEnabled = previousRegisterEnabled
common.OptionMap = previousOptionMap
DB = previousDB
})
DB = openTestSQLiteDB(t, "options-defaults.db")
common.RegisterEnabled = false
common.OptionMap = nil
InitOptionMap()
if got := common.OptionMap["RegisterEnabled"]; got != "false" {
t.Fatalf("expected RegisterEnabled default to be false, got %q", got)
}
if common.RegisterEnabled {
t.Fatal("expected RegisterEnabled to remain false after InitOptionMap")
}
}
+56
View File
@@ -0,0 +1,56 @@
package model
import "time"
type Origin struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"`
Address string `json:"address" gorm:"uniqueIndex;size:255;not null"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type OriginRouteCount struct {
OriginID uint `json:"origin_id"`
RouteCount int64 `json:"route_count"`
}
func ListOrigins() (origins []*Origin, err error) {
err = DB.Order("id desc").Find(&origins).Error
return origins, err
}
func GetOriginByID(id uint) (*Origin, error) {
origin := &Origin{}
err := DB.First(origin, id).Error
return origin, err
}
func GetOriginByAddress(address string) (*Origin, error) {
origin := &Origin{}
err := DB.Where("address = ?", address).First(origin).Error
return origin, err
}
func ListOriginRouteCounts() ([]OriginRouteCount, error) {
result := make([]OriginRouteCount, 0)
err := DB.Model(&ProxyRoute{}).
Select("origin_id, COUNT(*) AS route_count").
Where("origin_id IS NOT NULL").
Group("origin_id").
Scan(&result).Error
return result, err
}
func (origin *Origin) Insert() error {
return DB.Create(origin).Error
}
func (origin *Origin) Update() error {
return DB.Save(origin).Error
}
func (origin *Origin) Delete() error {
return DB.Delete(origin).Error
}
+55 -30
View File
@@ -3,22 +3,32 @@ package model
import "time"
type ProxyRoute struct {
ID uint `json:"id" gorm:"primaryKey"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
OriginHost string `json:"origin_host" gorm:"size:255"`
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
CertID *uint `json:"cert_id"`
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id" gorm:"primaryKey"`
SiteName string `json:"site_name" gorm:"size:255;not null;default:''"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"`
OriginID *uint `json:"origin_id" gorm:"index"`
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
OriginHost string `json:"origin_host" gorm:"size:255"`
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
CertID *uint `json:"cert_id"`
CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"`
DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"`
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"`
PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListProxyRoutes() (routes []*ProxyRoute, err error) {
@@ -27,7 +37,7 @@ func ListProxyRoutes() (routes []*ProxyRoute, err error) {
}
func GetEnabledProxyRoutes() (routes []*ProxyRoute, err error) {
err = DB.Where("enabled = ?", true).Order("domain asc").Find(&routes).Error
err = DB.Where("enabled = ?", true).Order("site_name asc").Order("domain asc").Find(&routes).Error
return routes, err
}
@@ -37,25 +47,40 @@ func GetProxyRouteByID(id uint) (*ProxyRoute, error) {
return route, err
}
func ListProxyRoutesByOriginID(originID uint) (routes []*ProxyRoute, err error) {
err = DB.Where("origin_id = ?", originID).Order("id desc").Find(&routes).Error
return routes, err
}
func (route *ProxyRoute) Insert() error {
return DB.Create(route).Error
}
func (route *ProxyRoute) Update() error {
return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{
"domain": route.Domain,
"origin_url": route.OriginURL,
"origin_host": route.OriginHost,
"upstreams": route.Upstreams,
"enabled": route.Enabled,
"enable_https": route.EnableHTTPS,
"cert_id": route.CertID,
"redirect_http": route.RedirectHTTP,
"cache_enabled": route.CacheEnabled,
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"remark": route.Remark,
"site_name": route.SiteName,
"domain": route.Domain,
"domains": route.Domains,
"origin_id": route.OriginID,
"origin_url": route.OriginURL,
"origin_host": route.OriginHost,
"upstreams": route.Upstreams,
"enabled": route.Enabled,
"enable_https": route.EnableHTTPS,
"cert_id": route.CertID,
"cert_ids": route.CertIDs,
"domain_cert_ids": route.DomainCertIDs,
"redirect_http": route.RedirectHTTP,
"limit_conn_per_server": route.LimitConnPerServer,
"limit_conn_per_ip": route.LimitConnPerIP,
"limit_rate": route.LimitRate,
"cache_enabled": route.CacheEnabled,
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"pow_enabled": route.PoWEnabled,
"pow_config": route.PoWConfig,
"remark": route.Remark,
}).Error
}
+231
View File
@@ -0,0 +1,231 @@
package model
import (
"fmt"
"sort"
"strconv"
"strings"
"sync"
"github.com/bwmarrin/snowflake"
"gorm.io/gorm"
"gorm.io/sharding"
)
const observabilityShardCount = 10
var (
observabilityIDNode *snowflake.Node
observabilityIDNodeErr error
observabilityIDNodeOnce sync.Once
)
func registerSharding(db *gorm.DB, backend string) error {
if db == nil {
return nil
}
_ = backend
if err := db.Use(sharding.Register(sharding.Config{
ShardingKey: "id",
NumberOfShards: observabilityShardCount,
ShardingAlgorithm: func(value any) (string, error) {
return observabilityShardSuffixForValue(value)
},
ShardingAlgorithmByPrimaryKey: func(id int64) string {
return observabilityShardSuffixForInt64(id)
},
PrimaryKeyGenerator: sharding.PKCustom,
PrimaryKeyGeneratorFn: func(tableIdx int64) int64 {
return 0
},
}, shardedObservabilityTables()...)); err != nil {
return fmt.Errorf("register observability sharding failed: %w", err)
}
return nil
}
func shardedObservabilityTables() []any {
return []any{
&NodeMetricSnapshot{},
&NodeRequestReport{},
&NodeAccessLog{},
}
}
func shardedObservabilityBaseTables() []string {
return []string{
"node_metric_snapshots",
"node_request_reports",
"node_access_logs",
}
}
func isShardedObservabilityTable(tableName string) bool {
switch strings.TrimSpace(tableName) {
case "node_metric_snapshots", "node_request_reports", "node_access_logs":
return true
default:
return false
}
}
func observabilityShardTables(baseTable string) []string {
tables := make([]string, 0, observabilityShardCount)
for _, suffix := range observabilityShardSuffixes() {
tables = append(tables, baseTable+suffix)
}
return tables
}
func observabilityShardSuffixes() []string {
suffixes := make([]string, 0, observabilityShardCount)
for index := 0; index < observabilityShardCount; index++ {
suffixes = append(suffixes, fmt.Sprintf("_%02d", index))
}
return suffixes
}
func observabilityShardSuffixForID(id uint) string {
return fmt.Sprintf("_%02d", uint64(id)%uint64(observabilityShardCount))
}
func observabilityShardSuffixForInt64(id int64) string {
if id < 0 {
id = -id
}
return fmt.Sprintf("_%02d", uint64(id)%uint64(observabilityShardCount))
}
func observabilityShardSuffixForValue(value any) (string, error) {
switch typed := value.(type) {
case int:
return observabilityShardSuffixForInt64(int64(typed)), nil
case int8:
return observabilityShardSuffixForInt64(int64(typed)), nil
case int16:
return observabilityShardSuffixForInt64(int64(typed)), nil
case int32:
return observabilityShardSuffixForInt64(int64(typed)), nil
case int64:
return observabilityShardSuffixForInt64(typed), nil
case uint:
return observabilityShardSuffixForID(typed), nil
case uint8:
return observabilityShardSuffixForID(uint(typed)), nil
case uint16:
return observabilityShardSuffixForID(uint(typed)), nil
case uint32:
return observabilityShardSuffixForID(uint(typed)), nil
case uint64:
return fmt.Sprintf("_%02d", typed%uint64(observabilityShardCount)), nil
case string:
id, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", fmt.Errorf("invalid sharding id %q", typed)
}
return fmt.Sprintf("_%02d", id%uint64(observabilityShardCount)), nil
default:
return "", fmt.Errorf("unsupported observability sharding value type %T", value)
}
}
func observabilityShardTableForID(baseTable string, id uint) string {
return baseTable + observabilityShardSuffixForID(id)
}
func legacyObservabilityShardTableName(tableName string) string {
return tableName + "_legacy_v2_to_v3"
}
func normalizeShardedDB(db *gorm.DB) *gorm.DB {
if db != nil {
return db
}
return DB
}
func sessionIgnoringSharding(db *gorm.DB) *gorm.DB {
db = normalizeShardedDB(db)
if db == nil {
return nil
}
return db.Session(&gorm.Session{}).Set(sharding.ShardingIgnoreStoreKey, true)
}
func baseDialector(db *gorm.DB) gorm.Dialector {
if db == nil {
return nil
}
if dialector, ok := db.Dialector.(sharding.ShardingDialector); ok {
return dialector.Dialector
}
return db.Dialector
}
func nextObservabilityID() (uint, error) {
observabilityIDNodeOnce.Do(func() {
observabilityIDNode, observabilityIDNodeErr = snowflake.NewNode(0)
})
if observabilityIDNodeErr != nil {
return 0, observabilityIDNodeErr
}
id := observabilityIDNode.Generate().Int64()
if id <= 0 {
return 0, fmt.Errorf("generated invalid observability id %d", id)
}
return uint(id), nil
}
func assignObservabilityID(id *uint) error {
if id == nil || *id != 0 {
return nil
}
generated, err := nextObservabilityID()
if err != nil {
return err
}
*id = generated
return nil
}
func queryAcrossShards[T any](baseTable string, query func(tx *gorm.DB) ([]T, error)) ([]T, error) {
return queryAcrossShardsWithDB(DB, baseTable, query)
}
func queryAcrossShardsWithDB[T any](db *gorm.DB, baseTable string, query func(tx *gorm.DB) ([]T, error)) ([]T, error) {
items := make([]T, 0)
db = normalizeShardedDB(db)
for _, table := range observabilityShardTables(baseTable) {
rows, err := query(db.Table(table))
if err != nil {
return nil, err
}
items = append(items, rows...)
}
return items, nil
}
func deleteAcrossShards(db *gorm.DB, baseTable string, model any, apply func(tx *gorm.DB) *gorm.DB) (int64, error) {
db = normalizeShardedDB(db)
var deleted int64
for _, table := range observabilityShardTables(baseTable) {
tx := db.Table(table)
if apply != nil {
tx = apply(tx)
} else {
tx = tx.Session(&gorm.Session{AllowGlobalUpdate: true})
}
result := tx.Delete(model)
if result.Error != nil {
return deleted, result.Error
}
deleted += result.RowsAffected
}
return deleted, nil
}
func sortShardRows[T any](items []T, less func(left T, right T) bool) {
sort.Slice(items, func(i int, j int) bool {
return less(items[i], items[j])
})
}
+12
View File
@@ -54,7 +54,9 @@ func SetApiRouter(router *gin.Engine) {
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.POST("/update", controller.UpdateOption)
optionRoute.POST("/update-batch", controller.UpdateOptionsBatch)
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
}
updateRoute := apiRouter.Group("/update")
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
@@ -77,10 +79,20 @@ func SetApiRouter(router *gin.Engine) {
proxyRoute.Use(middleware.AdminAuth())
{
proxyRoute.GET("/", controller.GetProxyRoutes)
proxyRoute.GET("/:id", controller.GetProxyRoute)
proxyRoute.POST("/", controller.CreateProxyRoute)
proxyRoute.POST("/:id/update", controller.UpdateProxyRoute)
proxyRoute.POST("/:id/delete", controller.DeleteProxyRoute)
}
originRoute := apiRouter.Group("/origins")
originRoute.Use(middleware.AdminAuth())
{
originRoute.GET("/", controller.GetOrigins)
originRoute.GET("/:id", controller.GetOrigin)
originRoute.POST("/", controller.CreateOrigin)
originRoute.POST("/:id/update", controller.UpdateOrigin)
originRoute.POST("/:id/delete", controller.DeleteOrigin)
}
managedDomainRoute := apiRouter.Group("/managed-domains")
managedDomainRoute.Use(middleware.AdminAuth())
{
+4 -4
View File
@@ -55,7 +55,7 @@ func TestPhase1PublishLifecycle(t *testing.T) {
"remark": "primary route",
}
resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody)
var createdRoute model.ProxyRoute
var createdRoute service.ProxyRouteView
decodeResponseData(t, resp, &createdRoute)
if createdRoute.Domain != "app.example.com" {
t.Fatalf("unexpected created route domain: %s", createdRoute.Domain)
@@ -74,7 +74,7 @@ func TestPhase1PublishLifecycle(t *testing.T) {
}
resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
var routes []model.ProxyRoute
var routes []service.ProxyRouteView
decodeResponseData(t, resp, &routes)
if len(routes) != 1 {
t.Fatalf("expected 1 route, got %d", len(routes))
@@ -289,7 +289,7 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
"redirect_http": true,
"remark": "https route",
})
var route model.ProxyRoute
var route service.ProxyRouteView
decodeResponseData(t, resp, &route)
if !route.EnableHTTPS || route.CertID == nil || *route.CertID != manualCertificate.ID {
t.Fatal("expected route to persist https certificate binding")
@@ -324,7 +324,7 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
}
listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
var routes []model.ProxyRoute
var routes []service.ProxyRouteView
decodeResponseData(t, listResp, &routes)
if len(routes) != 1 || !routes[0].EnableHTTPS || routes[0].CertID == nil || *routes[0].CertID != manualCertificate.ID || !routes[0].RedirectHTTP {
t.Fatalf("expected route list to reflect https update, got %+v", routes)
+211 -20
View File
@@ -41,21 +41,25 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
loginCookie := loginAsRoot(t, engine)
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "GlobalApiRateLimitNum",
"value": "450",
})
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "GlobalApiRateLimitDuration",
"value": "240",
})
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "CriticalRateLimitNum",
"value": "150",
})
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "CriticalRateLimitDuration",
"value": "900",
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update-batch", map[string]any{
"options": []map[string]any{
{
"key": "GlobalApiRateLimitNum",
"value": "450",
},
{
"key": "GlobalApiRateLimitDuration",
"value": "240",
},
{
"key": "CriticalRateLimitNum",
"value": "150",
},
{
"key": "CriticalRateLimitDuration",
"value": "900",
},
},
})
if common.GlobalApiRateLimitNum != 450 {
@@ -88,6 +92,115 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
}
}
func TestPhase2BatchOptionUpdateIsAtomic(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
model.InitOptionMap()
oldGlobalAPI := common.GlobalApiRateLimitNum
t.Cleanup(func() {
common.GlobalApiRateLimitNum = oldGlobalAPI
})
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
payload, err := json.Marshal(map[string]any{
"options": []map[string]any{
{
"key": "GlobalApiRateLimitNum",
"value": "451",
},
{
"key": "CriticalRateLimitDuration",
"value": "1800",
},
},
})
if err != nil {
t.Fatalf("failed to marshal batch payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/option/update-batch", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected status %d: %s", recorder.Code, recorder.Body.String())
}
var resp apiResponse
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Success {
t.Fatal("expected invalid batch update to fail")
}
if common.GlobalApiRateLimitNum != oldGlobalAPI {
t.Fatalf("expected GlobalApiRateLimitNum to remain %d after failed batch, got %d", oldGlobalAPI, common.GlobalApiRateLimitNum)
}
resp = performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/option/", nil)
var options []model.Option
decodeResponseData(t, resp, &options)
optionMap := make(map[string]string, len(options))
for _, option := range options {
optionMap[option.Key] = option.Value
}
if optionMap["GlobalApiRateLimitNum"] == "451" {
t.Fatal("expected failed batch update to avoid persisting partial values")
}
}
func TestPhase2BatchOptionUpdateValidatesMergedState(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
model.InitOptionMap()
oldGitHubClientID := common.GitHubClientId
oldGitHubOAuthEnabled := common.GitHubOAuthEnabled
t.Cleanup(func() {
common.GitHubClientId = oldGitHubClientID
common.GitHubOAuthEnabled = oldGitHubOAuthEnabled
})
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update-batch", map[string]any{
"options": []map[string]any{
{
"key": "GitHubClientId",
"value": "client-id-from-batch",
},
{
"key": "GitHubOAuthEnabled",
"value": "true",
},
},
})
if common.GitHubClientId != "client-id-from-batch" {
t.Fatalf("expected GitHubClientId to be updated from batch, got %q", common.GitHubClientId)
}
if !common.GitHubOAuthEnabled {
t.Fatal("expected GitHubOAuthEnabled to be enabled by merged batch state")
}
}
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
t.Helper()
payload, err := json.Marshal(map[string]any{
@@ -212,10 +325,10 @@ func TestPhase2AgentLifecycle(t *testing.T) {
"current_version": "",
"last_error": "",
}
resp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
resp := performAgentJSONRequestWithTokenAndRemote(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload, "198.51.100.10:1234")
var registeredNode model.Node
decodeResponseData(t, resp, &registeredNode)
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
if registeredNode.IP != "198.51.100.10" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
t.Fatal("expected heartbeat to update node metadata")
}
if registeredNode.OpenrestyStatus != service.OpenrestyStatusUnhealthy {
@@ -330,6 +443,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
restartHeartbeatReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader(rawHeartbeatPayload))
restartHeartbeatReq.Header.Set("Content-Type", "application/json")
restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
restartHeartbeatReq.RemoteAddr = "198.51.100.10:1234"
restartHeartbeatRecorder := httptest.NewRecorder()
engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq)
if restartHeartbeatRecorder.Code != http.StatusOK {
@@ -459,7 +573,7 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
{"key": "X-Trace-Id", "value": "$request_id"},
},
})
var createdRoute model.ProxyRoute
var createdRoute service.ProxyRouteView
decodeResponseData(t, createResp, &createdRoute)
if !strings.Contains(createdRoute.CustomHeaders, "X-Trace-Id") {
t.Fatalf("expected custom headers to be stored as json, got %s", createdRoute.CustomHeaders)
@@ -467,6 +581,9 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
if createdRoute.OriginHost != "preview-origin.internal" {
t.Fatalf("expected origin_host to be stored, got %s", createdRoute.OriginHost)
}
if createdRoute.SiteName != "preview.example.com" || createdRoute.PrimaryDomain != "preview.example.com" || createdRoute.DomainCount != 1 {
t.Fatalf("expected website identity fields in create response, got %+v", createdRoute)
}
performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
@@ -490,6 +607,9 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
var preview map[string]any
decodeResponseData(t, previewResp, &preview)
renderedConfig, _ := preview["rendered_config"].(string)
if websiteCount, ok := preview["website_count"].(float64); !ok || int(websiteCount) != 2 {
t.Fatalf("expected preview website_count=2, got %#v", preview["website_count"])
}
if !strings.Contains(renderedConfig, `proxy_set_header X-Release "candidate";`) {
t.Fatalf("expected preview endpoint to return custom header, got %s", renderedConfig)
}
@@ -514,6 +634,67 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
if !ok || len(addedDomains) != 1 || addedDomains[0].(string) != "new-preview.example.com" {
t.Fatalf("unexpected added domains: %#v", diff["added_domains"])
}
modifiedSites, ok := diff["modified_sites"].([]any)
if !ok || len(modifiedSites) != 1 || modifiedSites[0].(string) != "preview.example.com" {
t.Fatalf("unexpected modified sites: %#v", diff["modified_sites"])
}
addedSites, ok := diff["added_sites"].([]any)
if !ok || len(addedSites) != 1 || addedSites[0].(string) != "new-preview.example.com" {
t.Fatalf("unexpected added sites: %#v", diff["added_sites"])
}
}
func TestPhase2ProxyRouteWebsiteDetailAndLimits(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
token := prepareRootToken(t)
createResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{
"site_name": "marketing-site",
"domains": []string{"app.example.com", "www.example.com"},
"origin_url": "https://origin.internal",
"enabled": true,
"limit_conn_per_server": 120,
"limit_conn_per_ip": 12,
"limit_rate": "512K",
})
var createdRoute service.ProxyRouteView
decodeResponseData(t, createResp, &createdRoute)
if createdRoute.SiteName != "marketing-site" || createdRoute.PrimaryDomain != "app.example.com" {
t.Fatalf("unexpected create payload: %+v", createdRoute)
}
if createdRoute.DomainCount != 2 || len(createdRoute.Domains) != 2 || createdRoute.Domains[1] != "www.example.com" {
t.Fatalf("expected multi-domain website view, got %+v", createdRoute)
}
if createdRoute.LimitConnPerServer != 120 || createdRoute.LimitConnPerIP != 12 || createdRoute.LimitRate != "512k" {
t.Fatalf("expected normalized rate limit fields, got %+v", createdRoute)
}
if len(createdRoute.UpstreamList) != 1 || createdRoute.UpstreamList[0] != "https://origin.internal" {
t.Fatalf("expected structured upstream list, got %+v", createdRoute.UpstreamList)
}
detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/"+toString(createdRoute.ID), nil)
var detail service.ProxyRouteView
decodeResponseData(t, detailResp, &detail)
if detail.ID != createdRoute.ID || detail.SiteName != "marketing-site" || detail.LimitRate != "512k" {
t.Fatalf("unexpected detail response: %+v", detail)
}
if len(detail.Domains) != 2 || detail.Domains[0] != "app.example.com" || detail.Domains[1] != "www.example.com" {
t.Fatalf("expected detail response to expose full domain list, got %+v", detail.Domains)
}
listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
var routes []service.ProxyRouteView
decodeResponseData(t, listResp, &routes)
if len(routes) != 1 || routes[0].SiteName != "marketing-site" || routes[0].LimitConnPerServer != 120 {
t.Fatalf("unexpected proxy route list response: %+v", routes)
}
}
func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
@@ -533,7 +714,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
t.Fatal("expected global discovery token to be available")
}
resp := performAgentJSONRequestWithToken(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{
resp := performAgentJSONRequestWithTokenAndRemote(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{
"node_id": "local-node-id",
"name": "bulk-edge-1",
"ip": "10.0.0.18",
@@ -541,7 +722,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
"nginx_version": "1.25.5",
"current_version": "",
"last_error": "",
})
}, "203.0.113.18:4321")
var registration service.AgentRegistrationResponse
decodeResponseData(t, resp, &registration)
if registration.AgentToken == "" || registration.NodeID == "" {
@@ -557,9 +738,16 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Status != service.NodeStatusOnline {
t.Fatal("expected discovered node to be created online with issued agent token")
}
if nodes[0].IP != "203.0.113.18" {
t.Fatalf("expected discovered node to keep public source ip, got %s", nodes[0].IP)
}
}
func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
return performAgentJSONRequestWithTokenAndRemote(t, engine, token, method, path, body, "")
}
func performAgentJSONRequestWithTokenAndRemote(t *testing.T, engine http.Handler, token string, method string, path string, body any, remoteAddr string) apiResponse {
t.Helper()
var payload []byte
var err error
@@ -573,6 +761,9 @@ func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token s
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if remoteAddr != "" {
req.RemoteAddr = remoteAddr
}
req.Header.Set("X-Agent-Token", token)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
+49 -14
View File
@@ -2,6 +2,7 @@ package service
import (
"openflare/model"
"strings"
"testing"
"time"
)
@@ -60,9 +61,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
StatusCode: 200,
},
}
if err := model.DB.Create(&logs).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
}
seedNodeAccessLogs(t, logs)
result, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 2})
if err != nil {
@@ -121,9 +120,7 @@ func TestListAccessLogsUsesDefaultPageSize(t *testing.T) {
StatusCode: 200,
})
}
if err := model.DB.Create(&logs).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
}
seedNodeAccessLogs(t, logs)
result, err := ListAccessLogs(AccessLogQuery{})
if err != nil {
@@ -143,7 +140,7 @@ func TestListAccessLogsUsesDefaultPageSize(t *testing.T) {
func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
now := time.Date(2026, 3, 19, 8, 12, 30, 0, time.UTC)
if err := model.DB.Create(&model.Node{
NodeID: "node-folded",
Name: "edge-folded",
@@ -176,9 +173,7 @@ func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
StatusCode: 404,
},
}
if err := model.DB.Create(&logs).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
}
seedNodeAccessLogs(t, logs)
folded, err := ListFoldedAccessLogs(AccessLogQuery{
NodeID: "node-folded",
@@ -221,7 +216,7 @@ func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
if err := model.DB.Create([]*model.NodeAccessLog{
seedNodeAccessLogs(t, []*model.NodeAccessLog{
{
NodeID: "node-cleanup",
LoggedAt: now.Add(-10 * 24 * time.Hour),
@@ -238,9 +233,7 @@ func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) {
Path: "/recent",
StatusCode: 200,
},
}).Error; err != nil {
t.Fatalf("failed to seed cleanup logs: %v", err)
}
})
result, err := CleanupAccessLogs(AccessLogCleanupInput{RetentionDays: 7})
if err != nil {
@@ -258,3 +251,45 @@ func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) {
t.Fatalf("unexpected remaining logs after cleanup: %+v", remaining.Items)
}
}
func TestPersistNodeAccessLogsTruncatesLongPath(t *testing.T) {
setupServiceTestDB(t)
longPath := "/" + strings.Repeat("a", 140)
reportedAt := time.Now().UTC()
if err := persistNodeAccessLogs(model.DB, "node-truncate", []AgentNodeAccessLog{
{
LoggedAtUnix: reportedAt.Unix(),
RemoteAddr: "203.0.113.10",
Host: "truncate.example.com",
Path: longPath,
StatusCode: 200,
},
}, reportedAt); err != nil {
t.Fatalf("persistNodeAccessLogs failed: %v", err)
}
logs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{
NodeID: "node-truncate",
Page: 0,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListNodeAccessLogs failed: %v", err)
}
if len(logs) != 1 {
t.Fatalf("expected one stored log, got %+v", logs)
}
if got := len([]rune(logs[0].Path)); got != nodeAccessLogPathMaxLength {
t.Fatalf("expected truncated path length %d, got %d (%q)", nodeAccessLogPathMaxLength, got, logs[0].Path)
}
}
func seedNodeAccessLogs(t *testing.T, logs []*model.NodeAccessLog) {
t.Helper()
for _, item := range logs {
if err := model.DB.Create(item).Error; err != nil {
t.Fatalf("failed to seed access log: %v", err)
}
}
}
+4 -2
View File
@@ -217,7 +217,7 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
return nil, err
}
}
supportFiles = filterCertificateSupportFiles(supportFiles)
supportFiles = filterAgentSupportFiles(supportFiles)
slog.Debug("agent fetched active config", "version", version.Version, "checksum", version.Checksum)
return &AgentConfigResponse{
Version: version.Version,
@@ -230,7 +230,7 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
}, nil
}
func filterCertificateSupportFiles(files []SupportFile) []SupportFile {
func filterAgentSupportFiles(files []SupportFile) []SupportFile {
if len(files) == 0 {
return nil
}
@@ -240,6 +240,8 @@ func filterCertificateSupportFiles(files []SupportFile) []SupportFile {
switch {
case strings.HasSuffix(path, ".crt"), strings.HasSuffix(path, ".key"), strings.HasSuffix(path, ".pem"):
filtered = append(filtered, file)
case path == "pow_config.json":
filtered = append(filtered, file)
}
}
return filtered
+78
View File
@@ -0,0 +1,78 @@
package service
import (
"strings"
"testing"
)
func TestGetActiveConfigForAgentIncludesPoWConfig(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow-agent.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":4,"algorithm":"fast","session_ttl":86400,"challenge_ttl":300,"whitelist":{"paths":["/.well-known/*","/favicon.ico","/robots.txt"],"user_agents":["Googlebot","bingbot","Baiduspider"]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]}}`,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err := PublishConfigVersion("root"); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
activeConfig, err := GetActiveConfigForAgent()
if err != nil {
t.Fatalf("GetActiveConfigForAgent failed: %v", err)
}
foundPowConfig := false
for _, file := range activeConfig.SupportFiles {
if file.Path != "pow_config.json" {
continue
}
foundPowConfig = true
if file.Content == "" {
t.Fatal("expected pow_config.json content to be populated")
}
}
if !foundPowConfig {
t.Fatal("expected agent config to include pow_config.json support file")
}
}
func TestGetActiveConfigForAgentUsesTenMinutePoWSessionDefault(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow-default.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
PoWEnabled: true,
PoWConfig: `{}`,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err := PublishConfigVersion("root"); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
activeConfig, err := GetActiveConfigForAgent()
if err != nil {
t.Fatalf("GetActiveConfigForAgent failed: %v", err)
}
for _, file := range activeConfig.SupportFiles {
if file.Path == "pow_config.json" {
if !strings.Contains(file.Content, `"session_ttl":600`) {
t.Fatalf("expected default PoW session TTL to be 600 seconds, got %s", file.Content)
}
return
}
}
t.Fatal("expected agent config to include pow_config.json support file")
}
+582 -53
View File
@@ -35,6 +35,7 @@ type ConfigPreviewResult struct {
SupportFiles []SupportFile `json:"support_files"`
Checksum string `json:"checksum"`
RouteCount int `json:"route_count"`
WebsiteCount int `json:"website_count"`
}
type ConfigVersionSummary = model.ConfigVersionSummary
@@ -43,12 +44,17 @@ type ConfigVersionDetail = model.ConfigVersion
type ConfigDiffResult struct {
ActiveVersion string `json:"active_version,omitempty"`
AddedSites []string `json:"added_sites"`
RemovedSites []string `json:"removed_sites"`
ModifiedSites []string `json:"modified_sites"`
AddedDomains []string `json:"added_domains"`
RemovedDomains []string `json:"removed_domains"`
ModifiedDomains []string `json:"modified_domains"`
MainConfigChanged bool `json:"main_config_changed"`
ChangedOptionKeys []string `json:"changed_option_keys"`
ChangedOptionDetails []ConfigOptionDiffItem `json:"changed_option_details"`
CurrentWebsiteCount int `json:"current_website_count"`
ActiveWebsiteCount int `json:"active_website_count"`
}
type ConfigOptionDiffItem struct {
@@ -58,19 +64,28 @@ type ConfigOptionDiffItem struct {
}
type snapshotRoute struct {
Domain string `json:"domain"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host,omitempty"`
Upstreams []string `json:"upstreams,omitempty"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id,omitempty"`
RedirectHTTP bool `json:"redirect_http"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"`
Remark string `json:"remark,omitempty"`
SiteName string `json:"site_name,omitempty"`
Domain string `json:"domain"`
Domains []string `json:"domains,omitempty"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host,omitempty"`
Upstreams []string `json:"upstreams,omitempty"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id,omitempty"`
CertIDs []uint `json:"cert_ids,omitempty"`
DomainCertIDs []uint `json:"domain_cert_ids,omitempty"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
LimitRate string `json:"limit_rate,omitempty"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"`
PoWEnabled bool `json:"pow_enabled,omitempty"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"`
Remark string `json:"remark,omitempty"`
}
type routeCacheConfig struct {
@@ -79,6 +94,12 @@ type routeCacheConfig struct {
Rules []string
}
type routeLimitConfig struct {
LimitConnPerServer int
LimitConnPerIP int
LimitRate string
}
type routeUpstreamConfig struct {
Name string
Scheme string
@@ -205,6 +226,7 @@ func PreviewConfigVersion() (*ConfigPreviewResult, error) {
SupportFiles: bundle.SupportFiles,
Checksum: bundle.Checksum,
RouteCount: len(bundle.Routes),
WebsiteCount: len(bundle.SnapshotRoutes),
}, nil
}
@@ -214,21 +236,29 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
return nil, err
}
result := &ConfigDiffResult{
AddedSites: []string{},
RemovedSites: []string{},
ModifiedSites: []string{},
AddedDomains: []string{},
RemovedDomains: []string{},
ModifiedDomains: []string{},
ChangedOptionKeys: []string{},
ChangedOptionDetails: []ConfigOptionDiffItem{},
CurrentWebsiteCount: len(bundle.SnapshotRoutes),
}
activeVersion, err := model.GetActiveConfigVersion()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
for _, route := range bundle.SnapshotRoutes {
result.AddedDomains = append(result.AddedDomains, route.Domain)
result.AddedSites = append(result.AddedSites, route.SiteName)
result.AddedDomains = append(result.AddedDomains, route.Domains...)
}
result.MainConfigChanged = true
result.ChangedOptionKeys = openRestyOptionKeys()
result.ChangedOptionDetails = buildInitialOpenRestyOptionDiffs(bundle.OpenRestyConfig)
sort.Strings(result.AddedSites)
sort.Strings(result.AddedDomains)
sort.Strings(result.ChangedOptionKeys)
return result, nil
}
return nil, err
@@ -238,14 +268,26 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
if err != nil {
return nil, err
}
currentMap := make(map[string]snapshotRoute, len(bundle.SnapshotRoutes))
for _, route := range bundle.SnapshotRoutes {
currentMap[route.Domain] = route
result.ActiveWebsiteCount = len(activeSnapshot.Routes)
currentSiteMap := flattenSnapshotRoutesBySite(bundle.SnapshotRoutes)
activeSiteMap := flattenSnapshotRoutesBySite(activeSnapshot.Routes)
for siteName, currentRoute := range currentSiteMap {
activeRoute, ok := activeSiteMap[siteName]
if !ok {
result.AddedSites = append(result.AddedSites, siteName)
continue
}
if !snapshotRouteConfigEqual(activeRoute, currentRoute) {
result.ModifiedSites = append(result.ModifiedSites, siteName)
}
}
activeMap := make(map[string]snapshotRoute, len(activeSnapshot.Routes))
for _, route := range activeSnapshot.Routes {
activeMap[route.Domain] = route
for siteName := range activeSiteMap {
if _, ok := currentSiteMap[siteName]; !ok {
result.RemovedSites = append(result.RemovedSites, siteName)
}
}
currentMap := flattenSnapshotRoutesByDomain(bundle.SnapshotRoutes)
activeMap := flattenSnapshotRoutesByDomain(activeSnapshot.Routes)
for domain, currentRoute := range currentMap {
activeRoute, ok := activeMap[domain]
if !ok {
@@ -264,6 +306,9 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
result.MainConfigChanged = activeVersion.MainConfig != bundle.MainConfig
result.ChangedOptionDetails = diffOpenRestyOptionDetails(activeSnapshot.OpenRestyConfig, bundle.OpenRestyConfig)
result.ChangedOptionKeys = extractOptionDiffKeys(result.ChangedOptionDetails)
sort.Strings(result.AddedSites)
sort.Strings(result.RemovedSites)
sort.Strings(result.ModifiedSites)
sort.Strings(result.AddedDomains)
sort.Strings(result.RemovedDomains)
sort.Strings(result.ModifiedDomains)
@@ -386,7 +431,13 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
if err != nil {
return nil, err
}
powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes)
if err != nil {
return nil, err
}
supportFiles = append(supportFiles, powSupportFiles...)
mainConfig := renderMainConfig(openRestyConfig)
supportFiles = append(supportFiles, SupportFile{Path: "pow_config.json", Content: powConfigJSON})
return &configBundle{
Routes: routes,
SnapshotRoutes: snapshotRoutes,
@@ -403,6 +454,10 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
items := make([]snapshotRoute, 0, len(routes))
for _, route := range routes {
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
return nil, fmt.Errorf("route %s domains are invalid", route.Domain)
}
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
if err != nil {
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
@@ -415,25 +470,70 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
if err != nil {
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
}
powConfig, err := decodeStoredPoWConfig(route.PoWEnabled, route.PoWConfig)
if err != nil {
return nil, fmt.Errorf("路由 %s PoW 配置无效", route.Domain)
}
if !route.PoWEnabled {
powConfig = nil
}
items = append(items, snapshotRoute{
Domain: route.Domain,
OriginURL: route.OriginURL,
OriginHost: route.OriginHost,
Upstreams: upstreams,
Enabled: route.Enabled,
EnableHTTPS: route.EnableHTTPS,
CertID: route.CertID,
RedirectHTTP: route.RedirectHTTP,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
CustomHeaders: customHeaders,
Remark: route.Remark,
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]),
Domain: domains[0],
Domains: domains,
OriginURL: route.OriginURL,
OriginHost: route.OriginHost,
Upstreams: upstreams,
Enabled: route.Enabled,
EnableHTTPS: route.EnableHTTPS,
CertID: route.CertID,
CertIDs: mustDecodeSnapshotCertIDs(route),
DomainCertIDs: mustDecodeSnapshotDomainCertIDs(route, domains),
RedirectHTTP: route.RedirectHTTP,
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
CustomHeaders: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
Remark: route.Remark,
})
}
return items, nil
}
func mustDecodeSnapshotCertIDs(route *model.ProxyRoute) []uint {
if route == nil {
return []uint{}
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return []uint{}
}
return certIDs
}
func mustDecodeSnapshotDomainCertIDs(
route *model.ProxyRoute,
domains []string,
) []uint {
if route == nil {
return []uint{}
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return []uint{}
}
domainCertIDs, err := resolveProxyRouteDomainCertIDs(route, domains, certIDs)
if err != nil {
return []uint{}
}
return domainCertIDs
}
func parseSnapshotDocument(snapshotJSON string) (*snapshotDocument, error) {
text := strings.TrimSpace(snapshotJSON)
if text == "" {
@@ -459,10 +559,36 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
return []snapshotRoute{}
}
for index := range routes {
normalizedDomains, err := decodeStoredDomains("", routes[index].Domain)
if len(routes[index].Domains) > 0 {
normalizedDomains, err = normalizeProxyRouteDomains(routes[index].Domains)
}
if err == nil && len(normalizedDomains) > 0 {
routes[index].Domains = normalizedDomains
routes[index].Domain = normalizedDomains[0]
routes[index].SiteName = normalizeProxyRouteSiteNameInput(
&model.ProxyRoute{SiteName: routes[index].SiteName},
routes[index].SiteName,
normalizedDomains[0],
)
}
normalizedHeaders, err := normalizeCustomHeaders(routes[index].CustomHeaders)
if err == nil {
routes[index].CustomHeaders = normalizedHeaders
}
normalizedCertIDs, primaryCertID, err := normalizeSnapshotCertificateIDs(routes[index].CertID, routes[index].CertIDs)
if err == nil {
routes[index].CertID = primaryCertID
routes[index].CertIDs = normalizedCertIDs
}
normalizedDomainCertIDs, err := normalizeSnapshotDomainCertificateIDs(
routes[index].Domains,
routes[index].CertIDs,
routes[index].DomainCertIDs,
)
if err == nil {
routes[index].DomainCertIDs = normalizedDomainCertIDs
}
normalizedUpstreams, err := normalizeUpstreams(routes[index].OriginURL, routes[index].Upstreams)
if err == nil {
routes[index].OriginURL = normalizedUpstreams[0]
@@ -473,14 +599,57 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
routes[index].CachePolicy = normalizeCachePolicy(routes[index].CacheEnabled, routes[index].CachePolicy)
routes[index].CacheRules = normalizedCacheRules
}
normalizedLimitRate, err := normalizeProxyRouteLimitRate(routes[index].LimitRate)
if err == nil {
routes[index].LimitRate = normalizedLimitRate
}
if routes[index].PoWEnabled {
raw, err := json.Marshal(routes[index].PoWConfig)
if err == nil {
normalizedPoWConfig, err := normalizePoWConfig(true, string(raw))
if err == nil {
routes[index].PoWConfig = &normalizedPoWConfig
}
}
} else {
routes[index].PoWConfig = nil
}
}
return routes
}
func flattenSnapshotRoutesBySite(routes []snapshotRoute) map[string]snapshotRoute {
siteMap := make(map[string]snapshotRoute)
for _, route := range normalizeSnapshotRoutes(routes) {
siteMap[route.SiteName] = route
}
return siteMap
}
func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRoute {
domainMap := make(map[string]snapshotRoute)
for _, route := range normalizeSnapshotRoutes(routes) {
for _, domain := range route.Domains {
item := route
item.Domain = domain
domainMap[domain] = item
}
}
return domainMap
}
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || !uintPointerEqual(left.CertID, right.CertID) {
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
return false
}
if len(left.Domains) != len(right.Domains) {
return false
}
for index := range left.Domains {
if left.Domains[index] != right.Domains[index] {
return false
}
}
if len(left.Upstreams) != len(right.Upstreams) {
return false
}
@@ -505,6 +674,41 @@ func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
return false
}
}
if !snapshotPoWConfigEqual(left.PoWConfig, right.PoWConfig) {
return false
}
return true
}
func snapshotPoWConfigEqual(left *ProxyRoutePoWConfig, right *ProxyRoutePoWConfig) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return left.Difficulty == right.Difficulty &&
left.Algorithm == right.Algorithm &&
left.SessionTTL == right.SessionTTL &&
left.ChallengeTTL == right.ChallengeTTL &&
stringSliceEqual(left.Whitelist.IPs, right.Whitelist.IPs) &&
stringSliceEqual(left.Whitelist.IPCidrs, right.Whitelist.IPCidrs) &&
stringSliceEqual(left.Whitelist.Paths, right.Whitelist.Paths) &&
stringSliceEqual(left.Whitelist.PathRegexes, right.Whitelist.PathRegexes) &&
stringSliceEqual(left.Whitelist.UserAgents, right.Whitelist.UserAgents) &&
stringSliceEqual(left.Blacklist.IPs, right.Blacklist.IPs) &&
stringSliceEqual(left.Blacklist.IPCidrs, right.Blacklist.IPCidrs) &&
stringSliceEqual(left.Blacklist.Paths, right.Blacklist.Paths) &&
stringSliceEqual(left.Blacklist.PathRegexes, right.Blacklist.PathRegexes) &&
stringSliceEqual(left.Blacklist.UserAgents, right.Blacklist.UserAgents)
}
func stringSliceEqual(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
@@ -658,6 +862,15 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n")
supportFiles := make([]SupportFile, 0)
for _, route := range routes {
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
return "", nil, fmt.Errorf("route %s domains are invalid", route.Domain)
}
serverNames := renderServerNames(domains)
displayName := route.SiteName
if strings.TrimSpace(displayName) == "" {
displayName = domains[0]
}
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
if err != nil {
return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
@@ -675,31 +888,96 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
Policy: route.CachePolicy,
Rules: cacheRules,
}
limitConfig := routeLimitConfig{
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
}
upstreamConfig := buildRouteUpstreamConfig(route, upstreams)
if upstreamConfig.UsesNamedUpstream {
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
}
if !route.EnableHTTPS {
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
continue
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return "", nil, fmt.Errorf("route %s cert_ids are invalid: %w", route.Domain, err)
}
domainCertIDs, err := resolveProxyRouteDomainCertIDs(route, domains, certIDs)
if err != nil {
return "", nil, fmt.Errorf("route %s domain_cert_ids are invalid: %w", route.Domain, err)
}
if route.CertID == nil || *route.CertID == 0 {
return "", nil, fmt.Errorf("路由 %s 未配置证书", route.Domain)
}
certificate, err := model.GetTLSCertificateByID(*route.CertID)
if len(certIDs) == 0 {
return "", nil, fmt.Errorf("路由 %s 未配置证书", route.Domain)
}
certificates, err := loadTLSCertificates(certIDs)
if err != nil {
return "", nil, fmt.Errorf("路由 %s 关联证书不存在", route.Domain)
return "", nil, fmt.Errorf("route %s certificate lookup failed: %w", route.Domain, err)
}
supportFiles = append(supportFiles,
SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)},
SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)},
)
certificateByID := make(map[uint]*model.TLSCertificate, len(certificates))
for _, certificate := range certificates {
if certificate == nil {
continue
}
certificateByID[certificate.ID] = certificate
supportFiles = append(supportFiles,
SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)},
SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)},
)
}
httpOnlyDomains := make([]string, 0, len(domains))
domainsByCertID := make(map[uint][]string, len(certIDs))
for index, domain := range domains {
if index >= len(domainCertIDs) || domainCertIDs[index] == 0 {
httpOnlyDomains = append(httpOnlyDomains, domain)
continue
}
domainsByCertID[domainCertIDs[index]] = append(
domainsByCertID[domainCertIDs[index]],
domain,
)
}
for _, certID := range certIDs {
assignedDomains := domainsByCertID[certID]
if len(assignedDomains) == 0 {
continue
}
certificate := certificateByID[certID]
if certificate == nil {
return "", nil, fmt.Errorf("route %s certificate %d does not exist", route.Domain, certID)
}
if err := validateCertificateCoverage(certificate, assignedDomains); err != nil {
return "", nil, fmt.Errorf("site %s certificate validation failed: %w", displayName, err)
}
}
if route.RedirectHTTP {
builder.WriteString(renderHTTPRedirectServer(route.Domain))
if len(httpOnlyDomains) > 0 {
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
}
for _, certID := range certIDs {
assignedDomains := domainsByCertID[certID]
if len(assignedDomains) == 0 {
continue
}
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains)))
}
} else {
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
}
for _, certID := range certIDs {
assignedDomains := domainsByCertID[certID]
if len(assignedDomains) == 0 {
continue
}
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
}
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cacheConfig, upstreamConfig, cfg))
}
return builder.String(), dedupeSupportFiles(supportFiles), nil
}
@@ -772,7 +1050,8 @@ func renderTemplateDirective(enabled bool, statement string) string {
}
func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string {
lines := make([]string, 0, 8)
lines := make([]string, 0, 12)
lines = append(lines, renderOpenRestyLimitZoneBlock())
if !cfg.CacheEnabled {
lines = append(lines, renderOpenRestyObservabilityTemplateBlock())
return strings.Join(lines, "")
@@ -789,6 +1068,14 @@ func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string {
return strings.Join(lines, "")
}
func renderOpenRestyLimitZoneBlock() string {
return strings.Join([]string{
" limit_conn_zone $server_name zone=openflare_conn_per_server:10m;",
" limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;",
"",
}, "\n")
}
func onOff(value bool) string {
if value {
return "on"
@@ -796,6 +1083,90 @@ func onOff(value bool) string {
return "off"
}
const nginxPowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
func renderPowAccessBlock(powEnabled bool) string {
if !powEnabled {
return ""
}
return fmt.Sprintf(" access_by_lua_file %s/pow/check.lua;\n", nginxLuaDirPlaceholder)
}
func renderPowLocationBlocks(powEnabled bool) string {
if !powEnabled {
return ""
}
return fmt.Sprintf("\n location = %spass-challenge {\n content_by_lua_file %s/pow/verify.lua;\n }\n\n location = %smake-challenge {\n content_by_lua_file %s/pow/challenge.lua;\n }\n\n", anubisAPIPrefix, nginxLuaDirPlaceholder, anubisAPIPrefix, nginxLuaDirPlaceholder)
}
func renderPowStaticLocationBlock(powEnabled bool) string {
if !powEnabled {
return ""
}
return fmt.Sprintf(" location %s {\n alias %s/;\n types {\n text/css css;\n application/javascript js mjs;\n application/json json;\n image/webp webp;\n font/woff2 woff2;\n }\n }\n\n", anubisStaticPrefix, nginxPowStaticDirPlaceholder)
}
const anubisStaticPrefix = "/.within.website/x/cmd/anubis/static/"
const anubisAPIPrefix = "/.within.website/x/cmd/anubis/api/"
func normalizeSnapshotCertificateIDs(primaryCertID *uint, certIDs []uint) ([]uint, *uint, error) {
candidates := make([]uint, 0, len(certIDs)+1)
if primaryCertID != nil && *primaryCertID != 0 {
candidates = append(candidates, *primaryCertID)
}
candidates = append(candidates, certIDs...)
normalized := make([]uint, 0, len(candidates))
seen := make(map[uint]struct{}, len(candidates))
for _, certID := range candidates {
if certID == 0 {
continue
}
if _, ok := seen[certID]; ok {
continue
}
seen[certID] = struct{}{}
normalized = append(normalized, certID)
}
var normalizedPrimary *uint
if len(normalized) > 0 {
normalizedPrimary = &normalized[0]
}
return normalized, normalizedPrimary, nil
}
func normalizeSnapshotDomainCertificateIDs(
domains []string,
certIDs []uint,
domainCertIDs []uint,
) ([]uint, error) {
if len(domainCertIDs) > 0 {
if len(domains) > 0 && len(domainCertIDs) != len(domains) {
return nil, errors.New("snapshot domain_cert_ids length is invalid")
}
normalized := make([]uint, len(domainCertIDs))
copy(normalized, domainCertIDs)
return normalized, nil
}
if len(certIDs) == 0 {
return []uint{}, nil
}
if len(certIDs) == 1 {
normalized := make([]uint, len(domains))
for index := range normalized {
normalized[index] = certIDs[0]
}
return normalized, nil
}
if len(certIDs) == len(domains) {
normalized := make([]uint, len(certIDs))
copy(normalized, certIDs)
return normalized, nil
}
return []uint{}, nil
}
func uintPointerEqual(left *uint, right *uint) bool {
if left == nil || right == nil {
return left == nil && right == nil
@@ -803,6 +1174,18 @@ func uintPointerEqual(left *uint, right *uint) bool {
return *left == *right
}
func uintSliceEqual(left []uint, right []uint) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func checksum(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
@@ -836,18 +1219,91 @@ func nextVersionNumber(now time.Time) (string, error) {
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
}
func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
func renderHTTPProxyServer(serverNames string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, cfg openRestyConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderPowLocationBlocks(powEnabled), renderPowAccessBlock(powEnabled), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
}
func renderHTTPRedirectServer(domain string) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", domain)
func renderHTTPRedirectServer(serverNames string) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", serverNames)
}
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
func renderHTTPSServer(serverNames string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, cfg openRestyConfigSnapshot) string {
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, certPath, keyPath, renderPowLocationBlocks(powEnabled), renderPowAccessBlock(powEnabled), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
}
func renderHTTPSServerWithCertificates(serverNames string, originURL string, originHost string, certificateIDs []uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
var certificateBlock strings.Builder
for _, certificateID := range certificateIDs {
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
certificateBlock.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", certPath))
certificateBlock.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", keyPath))
}
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n%s\n location / {\n%s%s%s%s }\n}\n\n", serverNames, certificateBlock.String(), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
}
func renderServerNames(domains []string) string {
return strings.Join(domains, " ")
}
func validateCertificateCoverage(certificate *model.TLSCertificate, domains []string) error {
if certificate == nil {
return errors.New("certificate is nil")
}
leaf, err := parseLeafCertificate(certificate.CertPEM)
if err != nil {
return err
}
for _, domain := range domains {
if err := leaf.VerifyHostname(domain); err != nil {
return fmt.Errorf("certificate does not cover domain %s", domain)
}
}
return nil
}
func validateCertificateCoverageSet(certificates []*model.TLSCertificate, domains []string) error {
if len(certificates) == 0 {
return errors.New("certificate set is empty")
}
leaves := make([]interface{ VerifyHostname(string) error }, 0, len(certificates))
for _, certificate := range certificates {
if certificate == nil {
return errors.New("certificate is nil")
}
leaf, err := parseLeafCertificate(certificate.CertPEM)
if err != nil {
return err
}
leaves = append(leaves, leaf)
}
for _, domain := range domains {
covered := false
for _, leaf := range leaves {
if leaf.VerifyHostname(domain) == nil {
covered = true
break
}
}
if !covered {
return fmt.Errorf("certificate does not cover domain %s", domain)
}
}
return nil
}
func loadTLSCertificates(certIDs []uint) ([]*model.TLSCertificate, error) {
certificates := make([]*model.TLSCertificate, 0, len(certIDs))
for _, certID := range certIDs {
certificate, err := model.GetTLSCertificateByID(certID)
if err != nil {
return nil, err
}
certificates = append(certificates, certificate)
}
return certificates, nil
}
func renderConnectionUpgradeMap() string {
@@ -855,7 +1311,22 @@ func renderConnectionUpgradeMap() string {
}
func renderDefaultServerBlock() string {
return " server {\n listen 80 default_server;\n server_name _;\n\n return 404;\n }\n\n"
return strings.Join([]string{
" server {",
" listen 80 default_server;",
" server_name _;",
"",
" return 404;",
" }",
"",
" server {",
" listen 443 ssl default_server;",
" server_name _;",
"",
" ssl_reject_handshake on;",
" }",
"",
}, "\n")
}
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, upstreamConfig routeUpstreamConfig) string {
@@ -906,6 +1377,23 @@ func renderRouteCacheBlock(cacheConfig routeCacheConfig, cfg openRestyConfigSnap
return builder.String()
}
func renderRouteLimitBlock(limitConfig routeLimitConfig) string {
if limitConfig.LimitConnPerServer <= 0 && limitConfig.LimitConnPerIP <= 0 && strings.TrimSpace(limitConfig.LimitRate) == "" {
return ""
}
var builder strings.Builder
if limitConfig.LimitConnPerServer > 0 {
builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_server %d;\n", limitConfig.LimitConnPerServer))
}
if limitConfig.LimitConnPerIP > 0 {
builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_ip %d;\n", limitConfig.LimitConnPerIP))
}
if strings.TrimSpace(limitConfig.LimitRate) != "" {
builder.WriteString(fmt.Sprintf(" limit_rate %s;\n", limitConfig.LimitRate))
}
return builder.String()
}
func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string {
switch cacheConfig.Policy {
case proxyRouteCachePolicySuffix:
@@ -1022,6 +1510,10 @@ func buildUpstreamProxyPassURI(parsed *url.URL) string {
}
func buildRouteUpstreamName(route *model.ProxyRoute) string {
identity := strings.TrimSpace(route.SiteName)
if identity == "" {
identity = route.Domain
}
sanitized := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
@@ -1033,7 +1525,7 @@ func buildRouteUpstreamName(route *model.ProxyRoute) string {
default:
return '_'
}
}, route.Domain)
}, identity)
sanitized = strings.Trim(sanitized, "_")
if sanitized == "" {
sanitized = "backend"
@@ -1102,3 +1594,40 @@ func dedupeSupportFiles(files []SupportFile) []SupportFile {
}
return result
}
func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, error) {
type domainEntry struct {
Domains []string `json:"domains"`
Enabled bool `json:"enabled"`
Config map[string]interface{} `json:"config"`
}
entries := make([]domainEntry, 0)
hasPow := false
for _, route := range routes {
if !route.PoWEnabled {
continue
}
hasPow = true
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
return "", nil, err
}
var cfg map[string]interface{}
if err := json.Unmarshal([]byte(route.PoWConfig), &cfg); err != nil {
return "", nil, fmt.Errorf("route %s pow_config is invalid", route.Domain)
}
entries = append(entries, domainEntry{
Domains: domains,
Enabled: true,
Config: cfg,
})
}
if !hasPow {
return "{}", nil, nil
}
data, err := json.Marshal(entries)
if err != nil {
return "", nil, err
}
return string(data), nil, nil
}
@@ -0,0 +1,182 @@
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"openflare/common"
"openflare/model"
"strings"
"time"
)
const (
DatabaseCleanupTargetAccessLogs = "node_access_logs"
DatabaseCleanupTargetMetricSnapshots = "node_metric_snapshots"
DatabaseCleanupTargetRequestReports = "node_request_reports"
)
var databaseCleanupTargets = map[string]string{
DatabaseCleanupTargetAccessLogs: "访问日志",
DatabaseCleanupTargetMetricSnapshots: "性能快照",
DatabaseCleanupTargetRequestReports: "请求聚合",
}
type DatabaseCleanupInput struct {
Target string `json:"target"`
RetentionDays *int `json:"retention_days"`
}
type DatabaseCleanupResult struct {
Target string `json:"target"`
TargetLabel string `json:"target_label"`
DeletedCount int64 `json:"deleted_count"`
DeleteAll bool `json:"delete_all"`
RetentionDays *int `json:"retention_days,omitempty"`
Cutoff *time.Time `json:"cutoff,omitempty"`
}
type DatabaseAutoCleanupSummary struct {
RetentionDays int `json:"retention_days"`
ExecutedAt time.Time `json:"executed_at"`
Results []DatabaseCleanupResult `json:"results"`
}
func CleanupDatabaseObservability(input DatabaseCleanupInput) (*DatabaseCleanupResult, error) {
target := strings.TrimSpace(input.Target)
targetLabel, ok := databaseCleanupTargets[target]
if !ok {
return nil, errors.New("unsupported cleanup target")
}
if input.RetentionDays != nil && *input.RetentionDays <= 0 {
return nil, errors.New("retention_days 必须为大于 0 的整数")
}
result := &DatabaseCleanupResult{
Target: target,
TargetLabel: targetLabel,
DeleteAll: input.RetentionDays == nil,
}
if input.RetentionDays == nil {
deleted, err := deleteAllObservabilityRows(target)
if err != nil {
return nil, err
}
result.DeletedCount = deleted
return result, nil
}
retentionDays := *input.RetentionDays
cutoff := time.Now().UTC().Add(-time.Duration(retentionDays) * 24 * time.Hour)
deleted, err := deleteObservabilityRowsBefore(target, cutoff)
if err != nil {
return nil, err
}
result.DeletedCount = deleted
result.RetentionDays = &retentionDays
result.Cutoff = &cutoff
return result, nil
}
func RunDatabaseAutoCleanupOnce(now time.Time) (*DatabaseAutoCleanupSummary, error) {
if !common.DatabaseAutoCleanupEnabled {
return nil, nil
}
if common.DatabaseAutoCleanupRetentionDays < 1 {
return nil, fmt.Errorf("database auto cleanup retention_days must be at least 1")
}
retentionDays := common.DatabaseAutoCleanupRetentionDays
results := make([]DatabaseCleanupResult, 0, len(databaseCleanupTargets))
for _, target := range []string{
DatabaseCleanupTargetAccessLogs,
DatabaseCleanupTargetMetricSnapshots,
DatabaseCleanupTargetRequestReports,
} {
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
Target: target,
RetentionDays: &retentionDays,
})
if err != nil {
return nil, err
}
results = append(results, *result)
}
return &DatabaseAutoCleanupSummary{
RetentionDays: retentionDays,
ExecutedAt: now.UTC(),
Results: results,
}, nil
}
func StartDatabaseAutoCleanupScheduler(ctx context.Context) {
go func() {
for {
wait := time.Until(nextDatabaseAutoCleanupTime(time.Now()))
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
summary, err := RunDatabaseAutoCleanupOnce(time.Now())
if err != nil {
slog.Error("database auto cleanup failed", "error", err)
continue
}
if summary == nil {
continue
}
totalDeleted := int64(0)
for _, item := range summary.Results {
totalDeleted += item.DeletedCount
}
slog.Info(
"database auto cleanup completed",
"retention_days",
summary.RetentionDays,
"deleted_count",
totalDeleted,
)
}
}()
}
func nextDatabaseAutoCleanupTime(now time.Time) time.Time {
next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, now.Location())
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next
}
func deleteAllObservabilityRows(target string) (int64, error) {
switch target {
case DatabaseCleanupTargetAccessLogs:
return model.DeleteAllNodeAccessLogs(nil)
case DatabaseCleanupTargetMetricSnapshots:
return model.DeleteAllNodeMetricSnapshots(nil)
case DatabaseCleanupTargetRequestReports:
return model.DeleteAllNodeRequestReports(nil)
default:
return 0, errors.New("unsupported cleanup target")
}
}
func deleteObservabilityRowsBefore(target string, cutoff time.Time) (int64, error) {
switch target {
case DatabaseCleanupTargetAccessLogs:
return model.DeleteNodeAccessLogsBefore(cutoff)
case DatabaseCleanupTargetMetricSnapshots:
return model.DeleteNodeMetricSnapshotsBefore(nil, cutoff)
case DatabaseCleanupTargetRequestReports:
return model.DeleteNodeRequestReportsBefore(nil, cutoff)
default:
return 0, errors.New("unsupported cleanup target")
}
}
@@ -0,0 +1,165 @@
package service
import (
"openflare/common"
"openflare/model"
"testing"
"time"
)
func TestCleanupDatabaseObservabilityDeletesTargetedRows(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
if err := model.DB.Create(&model.NodeMetricSnapshot{
NodeID: "node-a",
CapturedAt: now.Add(-10 * 24 * time.Hour),
CPUUsagePercent: 10,
}).Error; err != nil {
t.Fatalf("seed old metric snapshot: %v", err)
}
if err := model.DB.Create(&model.NodeMetricSnapshot{
NodeID: "node-a",
CapturedAt: now.Add(-12 * time.Hour),
CPUUsagePercent: 20,
}).Error; err != nil {
t.Fatalf("seed recent metric snapshot: %v", err)
}
retentionDays := 7
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
Target: DatabaseCleanupTargetMetricSnapshots,
RetentionDays: &retentionDays,
})
if err != nil {
t.Fatalf("CleanupDatabaseObservability failed: %v", err)
}
if result.DeleteAll {
t.Fatal("expected retention cleanup instead of delete_all")
}
if result.DeletedCount != 1 {
t.Fatalf("expected 1 deleted row, got %+v", result)
}
rows, err := model.ListMetricSnapshotsSince(time.Time{})
if err != nil {
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
}
if len(rows) != 1 || rows[0].CPUUsagePercent != 20 {
t.Fatalf("unexpected remaining metric snapshots: %+v", rows)
}
}
func TestCleanupDatabaseObservabilityDeletesAllRowsWhenRetentionMissing(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
if err := model.DB.Create(&model.NodeAccessLog{
NodeID: "node-a",
LoggedAt: now.Add(-3 * time.Hour),
RemoteAddr: "203.0.113.1",
Host: "example.com",
Path: "/one",
StatusCode: 200,
}).Error; err != nil {
t.Fatalf("seed first access log: %v", err)
}
if err := model.DB.Create(&model.NodeAccessLog{
NodeID: "node-a",
LoggedAt: now.Add(-2 * time.Hour),
RemoteAddr: "203.0.113.2",
Host: "example.com",
Path: "/two",
StatusCode: 502,
}).Error; err != nil {
t.Fatalf("seed second access log: %v", err)
}
result, err := CleanupDatabaseObservability(DatabaseCleanupInput{
Target: DatabaseCleanupTargetAccessLogs,
})
if err != nil {
t.Fatalf("CleanupDatabaseObservability failed: %v", err)
}
if !result.DeleteAll || result.DeletedCount != 2 {
t.Fatalf("unexpected delete-all result: %+v", result)
}
rows, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10})
if err != nil {
t.Fatalf("ListNodeAccessLogs failed: %v", err)
}
if len(rows) != 0 {
t.Fatalf("expected all access logs deleted, got %+v", rows)
}
}
func TestRunDatabaseAutoCleanupOnceDeletesAllObservabilityTargets(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
if err := model.DB.Create(&model.NodeAccessLog{
NodeID: "node-a",
LoggedAt: now.Add(-48 * time.Hour),
RemoteAddr: "203.0.113.10",
Host: "example.com",
Path: "/access",
StatusCode: 200,
}).Error; err != nil {
t.Fatalf("seed access log: %v", err)
}
if err := model.DB.Create(&model.NodeMetricSnapshot{
NodeID: "node-a",
CapturedAt: now.Add(-48 * time.Hour),
CPUUsagePercent: 10,
}).Error; err != nil {
t.Fatalf("seed metric snapshot: %v", err)
}
if err := model.DB.Create(&model.NodeRequestReport{
NodeID: "node-a",
WindowStartedAt: now.Add(-49 * time.Hour),
WindowEndedAt: now.Add(-48 * time.Hour),
RequestCount: 15,
}).Error; err != nil {
t.Fatalf("seed request report: %v", err)
}
previousEnabled := common.DatabaseAutoCleanupEnabled
previousRetentionDays := common.DatabaseAutoCleanupRetentionDays
common.DatabaseAutoCleanupEnabled = true
common.DatabaseAutoCleanupRetentionDays = 1
t.Cleanup(func() {
common.DatabaseAutoCleanupEnabled = previousEnabled
common.DatabaseAutoCleanupRetentionDays = previousRetentionDays
})
summary, err := RunDatabaseAutoCleanupOnce(now)
if err != nil {
t.Fatalf("RunDatabaseAutoCleanupOnce failed: %v", err)
}
if summary == nil || len(summary.Results) != 3 {
t.Fatalf("unexpected auto cleanup summary: %+v", summary)
}
accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10})
if err != nil {
t.Fatalf("ListNodeAccessLogs failed: %v", err)
}
if len(accessLogs) != 0 {
t.Fatalf("expected auto cleanup to delete access logs, got %+v", accessLogs)
}
metricSnapshots, err := model.ListMetricSnapshotsSince(time.Time{})
if err != nil {
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
}
if len(metricSnapshots) != 0 {
t.Fatalf("expected auto cleanup to delete metric snapshots, got %+v", metricSnapshots)
}
requestReports, err := model.ListRequestReportsSince(time.Time{})
if err != nil {
t.Fatalf("ListRequestReportsSince failed: %v", err)
}
if len(requestReports) != 0 {
t.Fatalf("expected auto cleanup to delete request reports, got %+v", requestReports)
}
}
+403 -2
View File
@@ -5,6 +5,7 @@ import (
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"openflare/common"
@@ -110,11 +111,34 @@ func TestCreateProxyRouteRejectsHTTPSWithoutCertificate(t *testing.T) {
Enabled: true,
EnableHTTPS: true,
})
if err == nil || !strings.Contains(err.Error(), "必须选择证书") {
if err == nil || !strings.Contains(err.Error(), "must select a certificate") {
t.Fatalf("expected certificate validation error, got %v", err)
}
}
func TestCreateProxyRouteSupportsWebsiteDomains(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "main-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if route.SiteName != "main-site" {
t.Fatalf("unexpected site name: %s", route.SiteName)
}
if route.Domain != "app.example.com" {
t.Fatalf("expected primary domain mirror, got %s", route.Domain)
}
if len(route.Domains) != 2 || route.Domains[1] != "www.example.com" {
t.Fatalf("expected domains payload to contain alias, got %#v", route.Domains)
}
}
func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) {
setupServiceTestDB(t)
if err := model.UpdateOption("OpenRestyWebsocketEnabled", "true"); err != nil {
@@ -180,7 +204,7 @@ func TestCreateProxyRouteRejectsCachePolicyWithoutRules(t *testing.T) {
CacheEnabled: true,
CachePolicy: proxyRouteCachePolicySuffix,
})
if err == nil || !strings.Contains(err.Error(), "至少填写一个后缀") {
if err == nil || !strings.Contains(err.Error(), "at least one suffix") {
t.Fatalf("expected cache rule validation error, got %v", err)
}
}
@@ -300,6 +324,284 @@ func TestPublishConfigVersionRendersMultipleUpstreams(t *testing.T) {
}
}
func TestPublishConfigVersionRendersMultiDomainWebsite(t *testing.T) {
setupServiceTestDB(t)
certPEM, keyPEM := generateCertificatePair(t, []string{"app.example.com", "www.example.com"})
certificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "multi-domain",
CertPEM: certPEM,
KeyPEM: keyPEM,
})
if err != nil {
t.Fatalf("CreateTLSCertificate failed: %v", err)
}
_, err = CreateProxyRoute(ProxyRouteInput{
SiteName: "marketing-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
EnableHTTPS: true,
CertID: &certificate.ID,
RedirectHTTP: true,
CacheEnabled: true,
CachePolicy: proxyRouteCachePolicyPathPrefix,
CacheRules: []string{"/assets"},
CustomHeaders: []ProxyRouteCustomHeaderInput{{Key: "X-Site", Value: "marketing"}},
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "server_name app.example.com www.example.com;") {
t.Fatal("expected rendered config to include all domains in one server_name")
}
if strings.Contains(result.Version.RenderedConfig, "server_name app.example.com;") {
t.Fatal("expected rendered config to avoid standalone primary-domain server block")
}
if strings.Contains(result.Version.RenderedConfig, "server_name www.example.com;") {
t.Fatal("expected rendered config to avoid standalone alias server block")
}
if !strings.Contains(result.Version.SnapshotJSON, `"site_name":"marketing-site"`) {
t.Fatal("expected snapshot to include site_name")
}
if !strings.Contains(result.Version.SnapshotJSON, `"domains":["app.example.com","www.example.com"]`) {
t.Fatal("expected snapshot to include domain list")
}
}
func TestPublishConfigVersionRendersMultipleCertificatesForMultiDomainWebsite(t *testing.T) {
setupServiceTestDB(t)
appCertPEM, appKeyPEM := generateCertificatePair(t, []string{"app.example.com"})
appCertificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "app-only",
CertPEM: appCertPEM,
KeyPEM: appKeyPEM,
})
if err != nil {
t.Fatalf("CreateTLSCertificate app-only failed: %v", err)
}
wwwCertPEM, wwwKeyPEM := generateCertificatePair(t, []string{"www.example.com"})
wwwCertificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "www-only",
CertPEM: wwwCertPEM,
KeyPEM: wwwKeyPEM,
})
if err != nil {
t.Fatalf("CreateTLSCertificate www-only failed: %v", err)
}
route, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "marketing-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
EnableHTTPS: true,
DomainCertIDs: []uint{appCertificate.ID, wwwCertificate.ID},
RedirectHTTP: true,
CacheEnabled: true,
CachePolicy: proxyRouteCachePolicyPathPrefix,
CacheRules: []string{"/assets"},
CustomHeaders: []ProxyRouteCustomHeaderInput{{Key: "X-Site", Value: "marketing"}},
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if route.CertID == nil || *route.CertID != appCertificate.ID {
t.Fatalf("expected primary cert mirror to point at first certificate, got %#v", route.CertID)
}
if len(route.CertIDs) != 2 || route.CertIDs[0] != appCertificate.ID || route.CertIDs[1] != wwwCertificate.ID {
t.Fatalf("expected cert_ids to persist in order, got %#v", route.CertIDs)
}
if len(route.DomainCertIDs) != 2 || route.DomainCertIDs[0] != appCertificate.ID || route.DomainCertIDs[1] != wwwCertificate.ID {
t.Fatalf("expected domain_cert_ids to persist per domain, got %#v", route.DomainCertIDs)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if strings.Count(result.Version.RenderedConfig, "ssl_certificate __OPENFLARE_CERT_DIR__/") != 2 {
t.Fatalf("expected rendered config to include two ssl_certificate directives, got %s", result.Version.RenderedConfig)
}
if strings.Count(result.Version.RenderedConfig, "ssl_certificate_key __OPENFLARE_CERT_DIR__/") != 2 {
t.Fatalf("expected rendered config to include two ssl_certificate_key directives, got %s", result.Version.RenderedConfig)
}
if !strings.Contains(result.Version.SupportFilesJSON, certificateCertFileName(appCertificate.ID)) {
t.Fatal("expected support files to include first certificate")
}
if !strings.Contains(result.Version.SupportFilesJSON, certificateCertFileName(wwwCertificate.ID)) {
t.Fatal("expected support files to include second certificate")
}
if !strings.Contains(result.Version.SnapshotJSON, `"cert_ids":[`) {
t.Fatal("expected snapshot to include cert_ids")
}
if !strings.Contains(result.Version.SnapshotJSON, `"domain_cert_ids":[`) {
t.Fatal("expected snapshot to include domain_cert_ids")
}
}
func TestPublishConfigVersionSkipsHTTPSForDomainsWithoutCertificate(t *testing.T) {
setupServiceTestDB(t)
appCertPEM, appKeyPEM := generateCertificatePair(t, []string{"app.example.com"})
appCertificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "app-only",
CertPEM: appCertPEM,
KeyPEM: appKeyPEM,
})
if err != nil {
t.Fatalf("CreateTLSCertificate app-only failed: %v", err)
}
route, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "partial-https-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
EnableHTTPS: true,
DomainCertIDs: []uint{appCertificate.ID, 0},
RedirectHTTP: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if len(route.CertIDs) != 1 || route.CertIDs[0] != appCertificate.ID {
t.Fatalf("expected website cert_ids to keep used certificates only, got %#v", route.CertIDs)
}
if len(route.DomainCertIDs) != 2 || route.DomainCertIDs[0] != appCertificate.ID || route.DomainCertIDs[1] != 0 {
t.Fatalf("expected domain_cert_ids to preserve unassigned domains, got %#v", route.DomainCertIDs)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;\n http2 on;\n server_name app.example.com www.example.com;") {
t.Fatal("expected https server block to exclude domains without certificate")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;\n http2 on;\n server_name app.example.com;") {
t.Fatal("expected https server block to contain only the certified domain")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 80;\n server_name app.example.com;\n\n return 301 https://$host$request_uri;") {
t.Fatal("expected certified domain to keep http redirect")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 80;\n server_name www.example.com;") {
t.Fatal("expected non-certified domain to stay on plain http")
}
}
func TestDiffConfigVersionTracksAddedDomainWithinWebsite(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "main-site",
Domains: []string{"app.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err := PublishConfigVersion("root"); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if _, err := UpdateProxyRoute(route.ID, ProxyRouteInput{
SiteName: "main-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
}); err != nil {
t.Fatalf("UpdateProxyRoute failed: %v", err)
}
diff, err := DiffConfigVersion()
if err != nil {
t.Fatalf("DiffConfigVersion failed: %v", err)
}
if len(diff.AddedDomains) != 1 || diff.AddedDomains[0] != "www.example.com" {
t.Fatalf("unexpected added domains: %#v", diff.AddedDomains)
}
if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "app.example.com" {
t.Fatalf("unexpected modified domains: %#v", diff.ModifiedDomains)
}
if len(diff.ModifiedSites) != 1 || diff.ModifiedSites[0] != "main-site" {
t.Fatalf("unexpected modified sites: %#v", diff.ModifiedSites)
}
}
func TestCreateProxyRouteRejectsInvalidRateLimitFields(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "limit.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
LimitConnPerServer: -1,
})
if err == nil || !strings.Contains(err.Error(), "limit_conn_per_server") {
t.Fatalf("expected limit_conn_per_server validation error, got %v", err)
}
_, err = CreateProxyRoute(ProxyRouteInput{
Domain: "limit.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
LimitRate: "12x",
})
if err == nil || !strings.Contains(err.Error(), "limit_rate") {
t.Fatalf("expected limit_rate validation error, got %v", err)
}
}
func TestPublishConfigVersionRendersRouteRateLimits(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "limited-site",
Domains: []string{"limit.example.com", "www.limit.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
LimitConnPerServer: 120,
LimitConnPerIP: 12,
LimitRate: "512K",
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.MainConfig, "limit_conn_zone $server_name zone=openflare_conn_per_server:10m;") {
t.Fatal("expected main config to include server limit_conn_zone")
}
if !strings.Contains(result.Version.MainConfig, "limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;") {
t.Fatal("expected main config to include ip limit_conn_zone")
}
if !strings.Contains(result.Version.RenderedConfig, "limit_conn openflare_conn_per_server 120;") {
t.Fatal("expected rendered config to include per-server limit_conn")
}
if !strings.Contains(result.Version.RenderedConfig, "limit_conn openflare_conn_per_ip 12;") {
t.Fatal("expected rendered config to include per-ip limit_conn")
}
if !strings.Contains(result.Version.RenderedConfig, "limit_rate 512k;") {
t.Fatal("expected rendered config to include normalized limit_rate")
}
if !strings.Contains(result.Version.SnapshotJSON, `"limit_rate":"512k"`) {
t.Fatal("expected snapshot to include normalized limit_rate")
}
}
func TestPublishConfigVersionRendersHostnameLoadBalancingUpstream(t *testing.T) {
setupServiceTestDB(t)
@@ -622,6 +924,93 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
}
}
func TestPublishConfigVersionDetectsPoWChanges(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
firstRelease, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("initial PublishConfigVersion failed: %v", err)
}
if !strings.Contains(firstRelease.Version.SupportFilesJSON, `"path":"pow_config.json"`) {
t.Fatal("expected publish to include pow_config.json support file")
}
_, err = UpdateProxyRoute(route.ID, ProxyRouteInput{
Domain: route.Domain,
OriginURL: route.OriginURL,
Enabled: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":5,"algorithm":"slow","session_ttl":7200,"challenge_ttl":180,"whitelist":{"ips":["127.0.0.1"],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":["/login"],"path_regexes":[],"user_agents":[]}}`,
RedirectHTTP: false,
})
if err != nil {
t.Fatalf("UpdateProxyRoute failed: %v", err)
}
diff, err := DiffConfigVersion()
if err != nil {
t.Fatalf("DiffConfigVersion failed: %v", err)
}
if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "pow.example.com" {
t.Fatalf("expected PoW change to mark domain as modified, got %#v", diff.ModifiedDomains)
}
if len(diff.ModifiedSites) != 1 || diff.ModifiedSites[0] != "pow.example.com" {
t.Fatalf("expected PoW change to mark site as modified, got %#v", diff.ModifiedSites)
}
secondRelease, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion after PoW change failed: %v", err)
}
if firstRelease.Version.Checksum == secondRelease.Version.Checksum {
t.Fatal("expected PoW change to alter published checksum")
}
if !strings.Contains(secondRelease.Version.SnapshotJSON, `"pow_enabled":true`) {
t.Fatal("expected snapshot to persist PoW enabled state")
}
if !strings.Contains(secondRelease.Version.MainConfig, "lua_shared_dict openflare_pow_config 1m;") {
t.Fatal("expected main config to declare shared dict for pow config")
}
if !strings.Contains(secondRelease.Version.RenderedConfig, "location /.within.website/x/cmd/anubis/static/ {") {
t.Fatal("expected rendered config to expose anubis static location")
}
if strings.Contains(secondRelease.Version.RenderedConfig, "location /.within.website/x/cmd/anubis/static/static/ {") {
t.Fatal("expected rendered config to avoid duplicate static path segment")
}
if !strings.Contains(secondRelease.Version.RenderedConfig, "application/javascript js mjs;") {
t.Fatal("expected rendered config to serve Anubis module scripts with a JavaScript MIME type")
}
if !strings.Contains(secondRelease.Version.SnapshotJSON, `"difficulty":5`) {
t.Fatal("expected snapshot to persist PoW config")
}
var supportFiles []SupportFile
if err := json.Unmarshal([]byte(secondRelease.Version.SupportFilesJSON), &supportFiles); err != nil {
t.Fatalf("failed to decode support files: %v", err)
}
foundPowSupportFile := false
for _, file := range supportFiles {
if file.Path != "pow_config.json" {
continue
}
foundPowSupportFile = true
if !strings.Contains(file.Content, `"difficulty":5`) {
t.Fatalf("expected pow support file to persist config, got %s", file.Content)
}
}
if !foundPowSupportFile {
t.Fatal("expected publish to include pow_config.json support file")
}
}
func TestRenderConfigUsesDefaultServerFallback(t *testing.T) {
setupServiceTestDB(t)
@@ -648,12 +1037,18 @@ func TestRenderConfigUsesDefaultServerFallback(t *testing.T) {
if !strings.Contains(preview.MainConfig, "listen 80 default_server;") {
t.Fatal("expected preview main config to include default http server")
}
if !strings.Contains(preview.MainConfig, "listen 443 ssl default_server;") {
t.Fatal("expected preview main config to include default https server")
}
if !strings.Contains(preview.MainConfig, "server_name _;") {
t.Fatal("expected preview main config to include default server_name")
}
if !strings.Contains(preview.MainConfig, "return 404;") {
t.Fatal("expected preview main config to return 404 for unmatched hosts")
}
if !strings.Contains(preview.MainConfig, "ssl_reject_handshake on;") {
t.Fatal("expected preview main config to reject unmatched https handshakes")
}
}
func TestCreateTLSCertificateRejectsInvalidPEM(t *testing.T) {
@@ -706,6 +1101,12 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
if !strings.Contains(preview.MainConfig, "listen 80 default_server;") {
t.Fatal("expected preview main config to preserve managed default server block")
}
if !strings.Contains(preview.MainConfig, "listen 443 ssl default_server;") {
t.Fatal("expected preview main config to preserve managed default https server block")
}
if !strings.Contains(preview.MainConfig, "ssl_reject_handshake on;") {
t.Fatal("expected preview main config to preserve managed https handshake rejection")
}
invalidTemplate := strings.ReplaceAll(
common.OpenRestyMainConfigTemplate,
+27
View File
@@ -10,6 +10,7 @@ import (
"openflare/common"
"openflare/model"
"openflare/utils/geoip"
"openflare/utils/geoip/iputil"
"strings"
"time"
)
@@ -332,6 +333,29 @@ func cloneCoordinate(value *float64) *float64 {
return &cloned
}
func ResolveReportedNodeIP(reportedIP string, remoteAddr string) string {
reported := iputil.NormalizeIP(reportedIP)
remote := iputil.NormalizeRemoteAddr(remoteAddr)
if reported == "" {
return remote
}
if !shouldPreferRemoteNodeIP(reported) {
return reported
}
if isPublicNodeIP(remote) {
return remote
}
return reported
}
func shouldPreferRemoteNodeIP(ip string) bool {
return !isPublicNodeIP(ip)
}
func isPublicNodeIP(raw string) bool {
return iputil.IsPublicString(raw)
}
func buildNodeAgentReleaseView(node *model.Node, release *githubReleaseResponse, channel ReleaseChannel) *NodeAgentReleaseInfo {
currentVersion := strings.TrimSpace(node.AgentVersion)
view := &NodeAgentReleaseInfo{
@@ -428,6 +452,9 @@ func validateAgentNodePayload(payload AgentNodePayload) error {
if payload.IP == "" {
return errors.New("ip 不能为空")
}
if net.ParseIP(payload.IP) == nil {
return errors.New("ip 格式无效")
}
if payload.AgentVersion == "" {
return errors.New("agent_version 不能为空")
}
+66 -6
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"io"
"net"
"net/http"
@@ -327,6 +328,27 @@ func TestUpdateNodeCanChangeIPAndAutoResolveGeo(t *testing.T) {
}
}
func TestResolveReportedNodeIPPrefersPublicRemoteAddr(t *testing.T) {
resolved := ResolveReportedNodeIP("10.0.0.8", "198.51.100.20:9000")
if resolved != "198.51.100.20" {
t.Fatalf("expected public remote ip to override private reported ip, got %q", resolved)
}
}
func TestResolveReportedNodeIPKeepsPublicReportedAddr(t *testing.T) {
resolved := ResolveReportedNodeIP("8.8.8.8", "198.51.100.20:9000")
if resolved != "8.8.8.8" {
t.Fatalf("expected reported public ip to be preserved, got %q", resolved)
}
}
func TestResolveReportedNodeIPKeepsPrivateReportedAddrWhenRemoteIsPrivate(t *testing.T) {
resolved := ResolveReportedNodeIP("10.0.0.8", "172.16.1.10:9000")
if resolved != "10.0.0.8" {
t.Fatalf("expected private reported ip to be preserved when remote is also private, got %q", resolved)
}
}
func TestHeartbeatNodeResolvesGeoMetadataFromIPWhenNotManuallyOverridden(t *testing.T) {
setupServiceTestDB(t)
withFakeGeoIPProvider(t, &geoip.GeoInfo{
@@ -677,6 +699,9 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
Severity: NodeHealthSeverityCritical,
Message: "reload failed",
TriggeredAtUnix: time.Now().Add(-2 * time.Minute).Unix(),
Metadata: map[string]string{
"source": "runtime",
},
},
},
})
@@ -731,6 +756,16 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
if len(events) != 1 || events[0].EventType != "openresty_unhealthy" {
t.Fatalf("unexpected active health events: %+v", events)
}
if events[0].MetadataJSON == "" {
t.Fatal("expected metadata_json to persist")
}
var metadata map[string]string
if err := json.Unmarshal([]byte(events[0].MetadataJSON), &metadata); err != nil {
t.Fatalf("expected metadata_json to be valid json: %v", err)
}
if metadata["source"] != "runtime" {
t.Fatalf("unexpected metadata json: %+v", metadata)
}
}
func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
@@ -868,6 +903,15 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
TopDomains: map[string]int64{"edge.example.com": 40},
SourceCountries: map[string]int64{"CN": 20},
},
AccessLogs: []AgentNodeAccessLog{
{
LoggedAtUnix: now.Add(-110 * time.Second).Unix(),
RemoteAddr: "203.0.113.21",
Host: "edge.example.com",
Path: "/buffered",
StatusCode: 200,
},
},
},
},
})
@@ -889,6 +933,18 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) {
if len(reports) != 2 {
t.Fatalf("expected replay dedupe to keep report count stable, got %+v", reports)
}
accessLogs, err = model.ListNodeAccessLogs(model.NodeAccessLogQuery{
NodeID: node.NodeID,
Since: time.Time{},
Page: 0,
PageSize: 10,
})
if err != nil {
t.Fatalf("expected node access logs query to succeed after replay: %v", err)
}
if len(accessLogs) != 1 {
t.Fatalf("expected replay dedupe to keep access log count stable, got %+v", accessLogs)
}
}
func TestListAccessLogsUsesPagination(t *testing.T) {
@@ -908,7 +964,7 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
}
now := time.Now().UTC()
if err := model.DB.Create([]*model.NodeAccessLog{
for _, item := range []*model.NodeAccessLog{
{
NodeID: node.NodeID,
LoggedAt: now.Add(-10 * time.Second),
@@ -936,8 +992,10 @@ func TestListAccessLogsUsesPagination(t *testing.T) {
Path: "/three",
StatusCode: 502,
},
}).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
} {
if err := model.DB.Create(item).Error; err != nil {
t.Fatalf("failed to seed access logs: %v", err)
}
}
pageOne, err := ListAccessLogs(AccessLogQuery{
@@ -1404,7 +1462,7 @@ func TestGetDashboardOverview(t *testing.T) {
}).Insert(); err != nil {
t.Fatalf("failed to insert node b traffic report: %v", err)
}
if err := model.DB.Create([]*model.NodeAccessLog{
for _, item := range []*model.NodeAccessLog{
{
NodeID: "node-dashboard-a",
LoggedAt: now.Add(-30 * time.Minute),
@@ -1432,8 +1490,10 @@ func TestGetDashboardOverview(t *testing.T) {
Path: "/edge",
StatusCode: 502,
},
}).Error; err != nil {
t.Fatalf("failed to seed dashboard access logs: %v", err)
} {
if err := model.DB.Create(item).Error; err != nil {
t.Fatalf("failed to seed dashboard access logs: %v", err)
}
}
if err := model.DB.Create(&model.NodeHealthEvent{
+30 -19
View File
@@ -18,6 +18,7 @@ const (
NodeHealthSeverityWarning = "warning"
NodeHealthSeverityCritical = "critical"
nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour
nodeAccessLogPathMaxLength = 100
)
type AgentNodeSystemProfile struct {
@@ -151,7 +152,6 @@ func persistNodeSystemProfile(tx *gorm.DB, nodeID string, profile *AgentNodeSyst
TotalDiskBytes: profile.TotalDiskBytes,
UptimeSeconds: profile.UptimeSeconds,
ReportedAt: timeFromUnix(profile.ReportedAtUnix, reportedAt),
RawJSON: marshalJSON(profile),
}
return tx.Model(&model.NodeSystemProfile{}).Where("node_id = ?", nodeID).Assign(record).FirstOrCreate(record).Error
}
@@ -175,9 +175,15 @@ func persistNodeMetricSnapshot(tx *gorm.DB, nodeID string, snapshot *AgentNodeMe
OpenrestyRxBytes: snapshot.OpenrestyRxBytes,
OpenrestyTxBytes: snapshot.OpenrestyTxBytes,
OpenrestyConnections: snapshot.OpenrestyConnections,
RawJSON: marshalJSON(snapshot),
}
return tx.Where("node_id = ? AND captured_at = ?", nodeID, record.CapturedAt).Assign(record).FirstOrCreate(record).Error
exists, err := model.NodeMetricSnapshotExists(tx, nodeID, record.CapturedAt)
if err != nil {
return err
}
if exists {
return nil
}
return tx.Create(record).Error
}
func persistNodeTrafficReport(tx *gorm.DB, nodeID string, report *AgentNodeTrafficReport, reportedAt time.Time) error {
@@ -197,9 +203,15 @@ func persistNodeTrafficReport(tx *gorm.DB, nodeID string, report *AgentNodeTraff
StatusCodesJSON: marshalJSON(report.StatusCodes),
TopDomainsJSON: marshalJSON(report.TopDomains),
SourceCountriesJSON: marshalJSON(report.SourceCountries),
RawJSON: marshalJSON(report),
}
return tx.Where("node_id = ? AND window_started_at = ? AND window_ended_at = ?", nodeID, record.WindowStartedAt, record.WindowEndedAt).Assign(record).FirstOrCreate(record).Error
exists, err := model.NodeRequestReportExists(tx, nodeID, record.WindowStartedAt, record.WindowEndedAt)
if err != nil {
return err
}
if exists {
return nil
}
return tx.Create(record).Error
}
func persistNodeAccessLogs(tx *gorm.DB, nodeID string, logs []AgentNodeAccessLog, reportedAt time.Time) error {
@@ -220,26 +232,25 @@ func persistNodeAccessLogs(tx *gorm.DB, nodeID string, logs []AgentNodeAccessLog
RemoteAddr: strings.TrimSpace(item.RemoteAddr),
Region: "",
Host: strings.TrimSpace(item.Host),
Path: strings.TrimSpace(item.Path),
Path: truncateForDatabase(strings.TrimSpace(item.Path), nodeAccessLogPathMaxLength),
StatusCode: item.StatusCode,
RawJSON: marshalJSON(item),
}
if resolver != nil {
record.Region = resolver.Resolve(record.RemoteAddr)
}
if err := tx.Where(
"node_id = ? AND logged_at = ? AND remote_addr = ? AND host = ? AND path = ? AND status_code = ?",
nodeID,
record.LoggedAt,
record.RemoteAddr,
record.Host,
record.Path,
record.StatusCode,
).Assign(record).FirstOrCreate(record).Error; err != nil {
exists, err := model.NodeAccessLogExists(tx, record)
if err != nil {
return err
}
if exists {
continue
}
if err := tx.Create(record).Error; err != nil {
return err
}
}
return tx.Where("node_id = ? AND logged_at < ?", nodeID, reportedAt.Add(-nodeAccessLogRetentionWindow)).Delete(&model.NodeAccessLog{}).Error
_, err = model.DeleteNodeAccessLogsByNodeBefore(tx, nodeID, reportedAt.Add(-nodeAccessLogRetentionWindow))
return err
}
func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHealthEvent, reportedAt time.Time) error {
@@ -274,7 +285,7 @@ func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHea
existing.Message = normalizeHealthEventMessage(event.Message)
existing.LastTriggeredAt = triggeredAt
existing.ReportedAt = reportedAt
existing.RawJSON = marshalJSON(event)
existing.MetadataJSON = marshalJSON(event.Metadata)
existing.ResolvedAt = nil
if err := tx.Save(existing).Error; err != nil {
return err
@@ -290,7 +301,7 @@ func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHea
FirstTriggeredAt: triggeredAt,
LastTriggeredAt: triggeredAt,
ReportedAt: reportedAt,
RawJSON: marshalJSON(event),
MetadataJSON: marshalJSON(event.Metadata),
}
if err := tx.Create(record).Error; err != nil {
return err
@@ -11,6 +11,9 @@ const (
func renderOpenRestyObservabilityTemplateBlock() string {
return stringsJoinLines(
" lua_shared_dict openflare_observability 10m;",
" lua_shared_dict openflare_pow_config 1m;",
" lua_shared_dict openflare_pow_challenges 10m;",
" lua_shared_dict openflare_pow_sessions 20m;",
fmt.Sprintf(" init_worker_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityInitLuaPath),
fmt.Sprintf(" log_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityLogLuaPath),
"",
+241
View File
@@ -0,0 +1,241 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"openflare/model"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
type OriginInput struct {
Name string `json:"name"`
Address string `json:"address"`
Remark string `json:"remark"`
}
type OriginRouteSummary struct {
ID uint `json:"id"`
Domain string `json:"domain"`
OriginURL string `json:"origin_url"`
Enabled bool `json:"enabled"`
UpdatedAt string `json:"updated_at"`
}
type OriginView struct {
ID uint `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Remark string `json:"remark"`
RouteCount int64 `json:"route_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type OriginDetailView struct {
OriginView
Routes []OriginRouteSummary `json:"routes"`
}
func ListOrigins() ([]OriginView, error) {
origins, err := model.ListOrigins()
if err != nil {
return nil, err
}
return buildOriginViews(origins)
}
func GetOriginDetail(id uint) (*OriginDetailView, error) {
origin, err := model.GetOriginByID(id)
if err != nil {
return nil, err
}
views, err := buildOriginViews([]*model.Origin{origin})
if err != nil {
return nil, err
}
routes, err := model.ListProxyRoutesByOriginID(id)
if err != nil {
return nil, err
}
items := make([]OriginRouteSummary, 0, len(routes))
for _, route := range routes {
items = append(items, OriginRouteSummary{
ID: route.ID,
Domain: route.Domain,
OriginURL: route.OriginURL,
Enabled: route.Enabled,
UpdatedAt: route.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
sort.Slice(items, func(i int, j int) bool {
return items[i].Domain < items[j].Domain
})
detail := &OriginDetailView{
OriginView: views[0],
Routes: items,
}
return detail, nil
}
func CreateOrigin(input OriginInput) (*model.Origin, error) {
origin, err := buildOrigin(nil, input)
if err != nil {
return nil, err
}
if err = origin.Insert(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("源站地址已存在")
}
return nil, err
}
return origin, nil
}
func UpdateOrigin(id uint, input OriginInput) (*model.Origin, error) {
origin, err := model.GetOriginByID(id)
if err != nil {
return nil, err
}
previousAddress := origin.Address
nextOrigin, err := buildOrigin(origin, input)
if err != nil {
return nil, err
}
err = model.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(nextOrigin).Error; err != nil {
if isUniqueConstraintError(err) {
return errors.New("源站地址已存在")
}
return err
}
if previousAddress == nextOrigin.Address {
return nil
}
return updateRoutesForOriginAddress(tx, nextOrigin.ID, nextOrigin.Address)
})
if err != nil {
return nil, err
}
return nextOrigin, nil
}
func DeleteOrigin(id uint) error {
routes, err := model.ListProxyRoutesByOriginID(id)
if err != nil {
return err
}
if len(routes) > 0 {
return errors.New("该源站仍被规则引用,无法删除")
}
origin, err := model.GetOriginByID(id)
if err != nil {
return err
}
return origin.Delete()
}
func buildOrigin(existing *model.Origin, input OriginInput) (*model.Origin, error) {
address := normalizeOriginAddress(input.Address)
if err := validateOriginAddress(address); err != nil {
return nil, err
}
if existing == nil {
existing = &model.Origin{}
}
existing.Address = address
existing.Name = normalizeOriginName(input.Name, address)
existing.Remark = strings.TrimSpace(input.Remark)
return existing, nil
}
func getOrCreateOriginByAddress(address string) (*model.Origin, error) {
normalizedAddress := normalizeOriginAddress(address)
if err := validateOriginAddress(normalizedAddress); err != nil {
return nil, err
}
existing, err := model.GetOriginByAddress(normalizedAddress)
if err == nil {
return existing, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
origin := &model.Origin{
Name: normalizedAddress,
Address: normalizedAddress,
Remark: "",
}
if err := origin.Insert(); err != nil {
if isUniqueConstraintError(err) {
return model.GetOriginByAddress(normalizedAddress)
}
return nil, err
}
return origin, nil
}
func updateRoutesForOriginAddress(tx *gorm.DB, originID uint, address string) error {
var routes []*model.ProxyRoute
if err := tx.Where("origin_id = ?", originID).Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("query routes for origin update failed: %w", err)
}
for _, route := range routes {
rewrittenOriginURL, err := rewriteOriginURLAddress(route.OriginURL, address)
if err != nil {
return fmt.Errorf("rewrite route %d origin failed: %w", route.ID, err)
}
upstreams := make([]string, 0)
if strings.TrimSpace(route.Upstreams) != "" {
if err := json.Unmarshal([]byte(route.Upstreams), &upstreams); err != nil {
return fmt.Errorf("decode route %d upstreams failed: %w", route.ID, err)
}
}
if len(upstreams) == 0 {
upstreams = append(upstreams, rewrittenOriginURL)
} else {
upstreams[0] = rewrittenOriginURL
}
upstreamsJSON, err := json.Marshal(upstreams)
if err != nil {
return fmt.Errorf("encode route %d upstreams failed: %w", route.ID, err)
}
if err := tx.Model(&model.ProxyRoute{}).
Where("id = ?", route.ID).
Updates(map[string]any{
"origin_url": rewrittenOriginURL,
"upstreams": string(upstreamsJSON),
}).Error; err != nil {
return fmt.Errorf("update route %d origin address failed: %w", route.ID, err)
}
}
return nil
}
func buildOriginViews(origins []*model.Origin) ([]OriginView, error) {
countRows, err := model.ListOriginRouteCounts()
if err != nil {
return nil, err
}
countMap := make(map[uint]int64, len(countRows))
for _, row := range countRows {
countMap[row.OriginID] = row.RouteCount
}
views := make([]OriginView, 0, len(origins))
for _, origin := range origins {
views = append(views, OriginView{
ID: origin.ID,
Name: origin.Name,
Address: origin.Address,
Remark: origin.Remark,
RouteCount: countMap[origin.ID],
CreatedAt: origin.CreatedAt,
UpdatedAt: origin.UpdatedAt,
})
}
return views, nil
}
+192
View File
@@ -0,0 +1,192 @@
package service
import (
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"unicode"
)
func normalizeOriginAddress(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func validateOriginAddress(address string) error {
if address == "" {
return errors.New("源站地址不能为空")
}
if strings.Contains(address, "://") || strings.ContainsAny(address, "/?#") {
return errors.New("源站地址格式不合法")
}
if strings.HasPrefix(address, "[") || strings.HasSuffix(address, "]") {
return errors.New("源站地址无需包含 IPv6 方括号")
}
if ip := net.ParseIP(address); ip != nil {
return nil
}
if len(address) > 253 {
return errors.New("源站地址格式不合法")
}
labels := strings.Split(address, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return errors.New("源站地址格式不合法")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return errors.New("源站地址格式不合法")
}
for _, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
continue
}
return errors.New("源站地址格式不合法")
}
}
return nil
}
func normalizeOriginName(name string, address string) string {
normalized := strings.TrimSpace(name)
if normalized != "" {
return normalized
}
return address
}
func normalizeOriginPort(raw string) (string, error) {
port := strings.TrimSpace(raw)
if port == "" {
return "", errors.New("端口不能为空")
}
value, err := strconv.Atoi(port)
if err != nil || value < 1 || value > 65535 {
return "", errors.New("端口格式不合法")
}
return strconv.Itoa(value), nil
}
func normalizeOriginScheme(raw string) (string, error) {
scheme := strings.ToLower(strings.TrimSpace(raw))
switch scheme {
case "http", "https":
return scheme, nil
default:
return "", errors.New("源站协议仅支持 http 或 https")
}
}
func normalizeOriginURI(raw string) (string, error) {
uri := strings.TrimSpace(raw)
if uri == "" {
return "", nil
}
if strings.Contains(uri, "://") {
return "", errors.New("源站路径不能包含协议")
}
if !strings.HasPrefix(uri, "/") && !strings.HasPrefix(uri, "?") {
return "", errors.New("源站路径需以 / 或 ? 开头")
}
return uri, nil
}
func formatOriginHost(address string, port string) string {
if ip := net.ParseIP(address); ip != nil && strings.Contains(address, ":") {
return net.JoinHostPort(address, port)
}
return net.JoinHostPort(address, port)
}
func buildOriginURLFromParts(
scheme string,
address string,
port string,
uri string,
) (string, error) {
normalizedScheme, err := normalizeOriginScheme(scheme)
if err != nil {
return "", err
}
normalizedAddress := normalizeOriginAddress(address)
if err := validateOriginAddress(normalizedAddress); err != nil {
return "", err
}
normalizedPort, err := normalizeOriginPort(port)
if err != nil {
return "", err
}
normalizedURI, err := normalizeOriginURI(uri)
if err != nil {
return "", err
}
parsed := &url.URL{
Scheme: normalizedScheme,
Host: formatOriginHost(normalizedAddress, normalizedPort),
}
if normalizedURI != "" {
if strings.HasPrefix(normalizedURI, "?") {
parsed.RawQuery = strings.TrimPrefix(normalizedURI, "?")
} else {
pathQuery := strings.SplitN(normalizedURI, "?", 2)
parsed.Path = pathQuery[0]
if len(pathQuery) > 1 {
parsed.RawQuery = pathQuery[1]
}
}
}
return parsed.String(), nil
}
func extractOriginAddress(rawURL string) (string, error) {
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil {
return "", fmt.Errorf("源站地址格式不合法: %w", err)
}
address := normalizeOriginAddress(parsed.Hostname())
if err := validateOriginAddress(address); err != nil {
return "", err
}
return address, nil
}
func rewriteOriginURLAddress(rawURL string, newAddress string) (string, error) {
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil {
return "", fmt.Errorf("源站地址格式不合法: %w", err)
}
address := normalizeOriginAddress(newAddress)
if err := validateOriginAddress(address); err != nil {
return "", err
}
port := parsed.Port()
if port == "" {
return "", errors.New("源站地址缺少端口")
}
parsed.Host = formatOriginHost(address, port)
return parsed.String(), nil
}
func splitOriginURL(rawURL string) (scheme string, address string, port string, uri string, err error) {
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil {
return "", "", "", "", err
}
scheme = parsed.Scheme
address = normalizeOriginAddress(parsed.Hostname())
port = parsed.Port()
uri = parsed.EscapedPath()
if uri == "" {
uri = parsed.Path
}
if parsed.RawQuery != "" {
if uri == "" {
uri = "?" + parsed.RawQuery
} else {
uri = uri + "?" + parsed.RawQuery
}
}
return scheme, address, port, uri, nil
}
+105
View File
@@ -0,0 +1,105 @@
package service
import (
"testing"
"openflare/model"
)
func TestCreateProxyRouteStructuredOriginAutoCreatesOrigin(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "app.example.com",
OriginScheme: "https",
OriginAddress: "origin.internal",
OriginPort: "8443",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if route.OriginID == nil || *route.OriginID == 0 {
t.Fatal("expected route to be linked with an auto-created origin")
}
if route.OriginURL != "https://origin.internal:8443" {
t.Fatalf("unexpected route origin url: %s", route.OriginURL)
}
origin, err := model.GetOriginByID(*route.OriginID)
if err != nil {
t.Fatalf("GetOriginByID failed: %v", err)
}
if origin.Address != "origin.internal" {
t.Fatalf("unexpected origin address: %s", origin.Address)
}
}
func TestUpdateOriginRewritesLinkedRouteOriginURL(t *testing.T) {
setupServiceTestDB(t)
origin, err := CreateOrigin(OriginInput{
Name: "primary-origin",
Address: "origin-a.internal",
})
if err != nil {
t.Fatalf("CreateOrigin failed: %v", err)
}
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "app.example.com",
OriginID: &origin.ID,
OriginScheme: "https",
OriginPort: "8443",
OriginURI: "/api",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
updatedOrigin, err := UpdateOrigin(origin.ID, OriginInput{
Name: origin.Name,
Address: "origin-c.internal",
})
if err != nil {
t.Fatalf("UpdateOrigin failed: %v", err)
}
if updatedOrigin.Address != "origin-c.internal" {
t.Fatalf("unexpected updated origin address: %s", updatedOrigin.Address)
}
reloadedRoute, err := model.GetProxyRouteByID(route.ID)
if err != nil {
t.Fatalf("GetProxyRouteByID failed: %v", err)
}
if reloadedRoute.OriginURL != "https://origin-c.internal:8443/api" {
t.Fatalf("expected route origin url to be rewritten, got %s", reloadedRoute.OriginURL)
}
if reloadedRoute.Upstreams == "" || reloadedRoute.Upstreams == "[]" {
t.Fatalf("expected route upstreams to be preserved, got %s", reloadedRoute.Upstreams)
}
}
func TestDeleteOriginRejectsReferencedOrigin(t *testing.T) {
setupServiceTestDB(t)
origin, err := CreateOrigin(OriginInput{
Address: "origin-a.internal",
})
if err != nil {
t.Fatalf("CreateOrigin failed: %v", err)
}
if _, err = CreateProxyRoute(ProxyRouteInput{
Domain: "app.example.com",
OriginID: &origin.ID,
OriginScheme: "https",
OriginPort: "443",
Enabled: true,
}); err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if err = DeleteOrigin(origin.ID); err == nil {
t.Fatal("expected referenced origin deletion to fail")
}
}
File diff suppressed because it is too large Load Diff
+38 -10
View File
@@ -2,6 +2,7 @@ package service
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
@@ -54,7 +55,7 @@ func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, err
}
if err = certificate.Insert(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("证书名称已存在")
return nil, errors.New("certificate name already exists")
}
return nil, err
}
@@ -63,7 +64,7 @@ func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, err
func CreateTLSCertificateFromFiles(name string, certFile *multipart.FileHeader, keyFile *multipart.FileHeader, remark string) (*model.TLSCertificate, error) {
if certFile == nil || keyFile == nil {
return nil, errors.New("证书文件和私钥文件不能为空")
return nil, errors.New("certificate file and key file cannot be empty")
}
certContent, err := readMultipartFile(certFile)
if err != nil {
@@ -101,13 +102,40 @@ func UpdateTLSCertificate(id uint, input TLSCertificateInput) (*model.TLSCertifi
}
func DeleteTLSCertificate(id uint) error {
var routeCount int64
if err := model.DB.Model(&model.ProxyRoute{}).Where("cert_id = ?", id).Count(&routeCount).Error; err != nil {
routes, err := model.ListProxyRoutes()
if err != nil {
return err
}
if routeCount > 0 {
return errors.New("证书仍被反代规则引用,无法删除")
for _, route := range routes {
if route == nil {
continue
}
if route.CertID != nil && *route.CertID == id {
return errors.New("certificate is still referenced by proxy routes")
}
if strings.TrimSpace(route.CertIDs) == "" {
continue
}
var certIDs []uint
if err := json.Unmarshal([]byte(route.CertIDs), &certIDs); err != nil {
return fmt.Errorf("proxy route %d cert_ids payload is invalid: %w", route.ID, err)
}
for _, certID := range certIDs {
if certID == id {
return errors.New("certificate is still referenced by proxy routes")
}
}
domainCertIDs, err := decodeStoredDomainCertIDs(route.DomainCertIDs, 0)
if err != nil {
return fmt.Errorf("proxy route %d domain_cert_ids payload is invalid: %w", route.ID, err)
}
for _, certID := range domainCertIDs {
if certID == id {
return errors.New("certificate is still referenced by proxy routes")
}
}
}
certificate, err := model.GetTLSCertificateByID(id)
if err != nil {
return err
@@ -121,17 +149,17 @@ func buildTLSCertificate(existing *model.TLSCertificate, input TLSCertificateInp
keyPEM := strings.TrimSpace(input.KeyPEM)
remark := strings.TrimSpace(input.Remark)
if name == "" {
return nil, errors.New("证书名称不能为空")
return nil, errors.New("certificate name cannot be empty")
}
if certPEM == "" || keyPEM == "" {
return nil, errors.New("证书内容和私钥内容不能为空")
return nil, errors.New("certificate content and key content cannot be empty")
}
parsed, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, fmt.Errorf("证书或私钥格式不合法: %w", err)
return nil, fmt.Errorf("certificate or key format is invalid: %w", err)
}
if len(parsed.Certificate) == 0 {
return nil, errors.New("证书内容不合法")
return nil, errors.New("certificate content is invalid")
}
leaf, err := parseLeafCertificate(certPEM)
if err != nil {
@@ -0,0 +1,66 @@
package iputil
import (
"net"
"strings"
)
func NormalizeIP(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
ip := net.ParseIP(trimmed)
if ip == nil {
return ""
}
if ipv4 := ip.To4(); ipv4 != nil {
return ipv4.String()
}
return ip.String()
}
func NormalizeRemoteAddr(remoteAddr string) string {
trimmed := strings.TrimSpace(remoteAddr)
if trimmed == "" {
return ""
}
if host, _, err := net.SplitHostPort(trimmed); err == nil {
return NormalizeIP(host)
}
return NormalizeIP(trimmed)
}
func IsPublic(ip net.IP) bool {
if ip == nil {
return false
}
if ipv4 := ip.To4(); ipv4 != nil {
ip = ipv4
}
if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() {
return false
}
return true
}
func IsPublicString(raw string) bool {
ip := net.ParseIP(strings.TrimSpace(raw))
return IsPublic(ip)
}
func Score(ip net.IP) int {
if ip == nil {
return -1
}
if ipv4 := ip.To4(); ipv4 != nil {
ip = ipv4
}
if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsMulticast() || ip.IsUnspecified() {
return -1
}
if IsPublic(ip) {
return 2
}
return 1
}
@@ -0,0 +1,45 @@
package iputil
import (
"net"
"testing"
)
func TestNormalizeIP(t *testing.T) {
if got := NormalizeIP(" 8.8.8.8 "); got != "8.8.8.8" {
t.Fatalf("unexpected normalized ipv4: %q", got)
}
if got := NormalizeIP("[::1]"); got != "" {
t.Fatalf("expected invalid bracketed host to be rejected, got %q", got)
}
}
func TestNormalizeRemoteAddr(t *testing.T) {
if got := NormalizeRemoteAddr("203.0.113.10:8443"); got != "203.0.113.10" {
t.Fatalf("unexpected remote addr normalization: %q", got)
}
}
func TestIsPublic(t *testing.T) {
if !IsPublic(net.ParseIP("8.8.8.8")) {
t.Fatal("expected public ip to be detected")
}
if IsPublic(net.ParseIP("10.0.0.8")) {
t.Fatal("expected private ip to be rejected")
}
if IsPublic(net.ParseIP("127.0.0.1")) {
t.Fatal("expected loopback ip to be rejected")
}
}
func TestScore(t *testing.T) {
if got := Score(net.ParseIP("8.8.8.8")); got != 2 {
t.Fatalf("unexpected score for public ip: %d", got)
}
if got := Score(net.ParseIP("10.0.0.8")); got != 1 {
t.Fatalf("unexpected score for private ip: %d", got)
}
if got := Score(net.ParseIP("127.0.0.1")); got != -1 {
t.Fatalf("unexpected score for loopback ip: %d", got)
}
}
@@ -0,0 +1,11 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { OriginDetailPage } from '@/features/origins/components/origin-detail-page';
export default function OriginDetailRoute() {
const searchParams = useSearchParams();
return <OriginDetailPage originId={searchParams.get('id') ?? ''} />;
}
@@ -0,0 +1,5 @@
import { OriginsPage } from '@/features/origins/components/origins-page';
export default function OriginRoute() {
return <OriginsPage />;
}
@@ -0,0 +1,16 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { ProxyRouteConfigPage } from '@/features/proxy-routes/components/proxy-route-config-page';
export default function ProxyRouteDetailRoute() {
const searchParams = useSearchParams();
return (
<ProxyRouteConfigPage
routeId={searchParams.get('id') ?? ''}
initialSection={searchParams.get('section') ?? 'domains'}
/>
);
}
+139 -10
View File
@@ -1,22 +1,27 @@
@import '../styles/tokens.css';
@import 'tailwindcss';
@import '../styles/tokens.css';
@import 'tailwindcss';
@import '@heroui/styles';
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
* {
box-sizing: border-box;
}
html {
background: var(--surface-canvas);
}
html[data-theme='light'] {
color-scheme: light;
}
html[data-theme='dark'] {
color-scheme: dark;
}
body {
min-height: 100vh;
background: var(--body-background);
@@ -28,17 +33,141 @@ body {
background 0.2s ease,
color 0.2s ease;
}
a {
color: inherit;
text-decoration: none;
}
button {
cursor: pointer;
}
::selection {
background: var(--selection-background);
color: var(--selection-foreground);
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+10 -6
View File
@@ -6,13 +6,17 @@ import { AppProviders } from '@/components/providers/app-providers';
import { getThemeInitScript } from '@/lib/theme/theme';
import './globals.css';
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: {
default: 'OpenFlare 控制台',
template: '%s | OpenFlare',
},
description: 'OpenFlare 管理端新版工程骨架',
description: 'OpenFlare 管理端',
applicationName: 'OpenFlare',
};
@@ -20,9 +24,9 @@ interface RootLayoutProps {
children: ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang='zh-CN' suppressHydrationWarning>
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}>
<body>
<Script id='theme-init' strategy='beforeInteractive'>
{getThemeInitScript()}
@@ -30,5 +34,5 @@ export default function RootLayout({ children }: RootLayoutProps) {
<AppProviders>{children}</AppProviders>
</body>
</html>
);
}
);
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
@@ -47,6 +47,13 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
<path d="M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z" />
</svg>
);
case 'origin':
return (
<svg {...commonProps}>
<path d="M12 4.5c3.3 0 6 2.5 6 5.6 0 4.4-6 9.4-6 9.4s-6-5-6-9.4c0-3.1 2.7-5.6 6-5.6Z" />
<circle cx="12" cy="10.1" r="1.9" />
</svg>
);
case 'domain':
return (
<svg {...commonProps}>
@@ -0,0 +1,312 @@
'use client';
import {
cloneElement,
createContext,
isValidElement,
useContext,
useEffect,
useId,
useMemo,
useState,
type HTMLAttributes,
type ReactElement,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils/cn';
type DrawerDirection = 'top' | 'right' | 'bottom' | 'left';
type DrawerContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
direction: DrawerDirection;
};
const DrawerContext = createContext<DrawerContextValue | null>(null);
function useDrawerContext() {
const context = useContext(DrawerContext);
if (!context) {
throw new Error('Drawer components must be used within Drawer.');
}
return context;
}
function renderWithOptionalChild(
child: ReactNode,
props: Record<string, unknown>,
fallback: ReactNode,
) {
if (isValidElement(child)) {
return cloneElement(child as ReactElement, props);
}
return fallback;
}
export function Drawer({
children,
open,
defaultOpen = false,
onOpenChange,
direction = 'bottom',
title,
description,
footer,
}: {
children: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
direction?: DrawerDirection;
title?: string;
description?: string;
footer?: ReactNode;
size?: 'md' | 'lg' | 'xl';
}) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const resolvedOpen = isControlled ? open : internalOpen;
const value = useMemo<DrawerContextValue>(
() => ({
open: resolvedOpen,
direction,
setOpen: (nextOpen) => {
if (!isControlled) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
},
}),
[direction, isControlled, onOpenChange, resolvedOpen],
);
return (
<DrawerContext.Provider value={value}>
{title || description || footer ? (
<DrawerContent
aria-label={title}
className={cn(
'w-full md:w-[50vw] md:max-w-none',
direction === 'right' || direction === 'left' ? '' : 'max-h-[85vh]',
)}
>
<DrawerHeader className="flex items-start justify-between gap-4">
<div className="min-w-0">
{title ? <DrawerTitle>{title}</DrawerTitle> : null}
{description ? (
<DrawerDescription>{description}</DrawerDescription>
) : null}
</div>
<button
type="button"
onClick={() => value.setOpen(false)}
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
aria-label="关闭抽屉"
>
×
</button>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
{children}
</div>
{footer ? <DrawerFooter>{footer}</DrawerFooter> : null}
</DrawerContent>
) : (
children
)}
</DrawerContext.Provider>
);
}
export function DrawerTrigger({
children,
asChild = false,
}: {
children: ReactNode;
asChild?: boolean;
}) {
const { setOpen } = useDrawerContext();
if (asChild) {
return renderWithOptionalChild(
children,
{
onClick: () => setOpen(true),
},
children,
);
}
return (
<button type="button" onClick={() => setOpen(true)}>
{children}
</button>
);
}
export function DrawerClose({
children,
asChild = false,
}: {
children: ReactNode;
asChild?: boolean;
}) {
const { setOpen } = useDrawerContext();
if (asChild) {
return renderWithOptionalChild(
children,
{
onClick: () => setOpen(false),
},
children,
);
}
return (
<button type="button" onClick={() => setOpen(false)}>
{children}
</button>
);
}
export function DrawerContent({
children,
className,
'aria-label': ariaLabel,
}: HTMLAttributes<HTMLDivElement>) {
const { open, setOpen, direction } = useDrawerContext();
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
if (!open) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, setOpen]);
if (!open || typeof document === 'undefined') {
return null;
}
const positionClassName =
direction === 'right'
? 'inset-y-0 right-0 h-full border-l'
: direction === 'left'
? 'inset-y-0 left-0 h-full border-r'
: direction === 'top'
? 'inset-x-0 top-0 border-b'
: 'inset-x-0 bottom-0 border-t';
return createPortal(
<div className="fixed inset-0 z-50">
<button
type="button"
className="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]"
onClick={() => setOpen(false)}
aria-label="关闭抽屉"
/>
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : titleId}
aria-describedby={descriptionId}
className={cn(
'absolute flex w-full flex-col overflow-hidden border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
positionClassName,
className,
)}
>
<DrawerMetaContext.Provider value={{ titleId, descriptionId }}>
{children}
</DrawerMetaContext.Provider>
</div>
</div>,
document.body,
);
}
type DrawerMetaContextValue = {
titleId: string;
descriptionId: string;
};
const DrawerMetaContext = createContext<DrawerMetaContextValue | null>(null);
function useDrawerMetaContext() {
const context = useContext(DrawerMetaContext);
if (!context) {
throw new Error('Drawer title and description must be used within DrawerContent.');
}
return context;
}
export function DrawerHeader({
className,
...props
}: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('border-b border-[var(--border-default)] px-6 py-5', className)} {...props} />;
}
export function DrawerFooter({
className,
...props
}: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('border-t border-[var(--border-default)] px-6 py-4', className)} {...props} />;
}
export function DrawerTitle({
className,
...props
}: HTMLAttributes<HTMLHeadingElement>) {
const { titleId } = useDrawerMetaContext();
return (
<h2
id={titleId}
className={cn('text-xl font-semibold text-[var(--foreground-primary)]', className)}
{...props}
/>
);
}
export function DrawerDescription({
className,
...props
}: HTMLAttributes<HTMLParagraphElement>) {
const { descriptionId } = useDrawerMetaContext();
return (
<p
id={descriptionId}
className={cn('mt-2 text-sm leading-6 text-[var(--foreground-secondary)]', className)}
{...props}
/>
);
}
@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Label = React.forwardRef<
HTMLLabelElement,
React.ComponentProps<'label'>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
'text-sm font-medium leading-none text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = 'Label';
export { Label };
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,70 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
type SwitchProps = Omit<React.ComponentProps<'button'>, 'onChange'> & {
checked?: boolean;
defaultChecked?: boolean;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
};
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
(
{
className,
checked,
defaultChecked = false,
disabled,
onCheckedChange,
...props
},
ref,
) => {
const [internalChecked, setInternalChecked] = React.useState(defaultChecked);
const isControlled = checked !== undefined;
const isChecked = isControlled ? checked : internalChecked;
const updateChecked = React.useCallback(
(nextChecked: boolean) => {
if (!isControlled) {
setInternalChecked(nextChecked);
}
onCheckedChange?.(nextChecked);
},
[isControlled, onCheckedChange],
);
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={isChecked}
data-state={isChecked ? 'checked' : 'unchecked'}
disabled={disabled}
className={cn(
'peer inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-transparent bg-[var(--control-background-hover)] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--brand-primary)]',
className,
)}
onClick={() => updateChecked(!isChecked)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
updateChecked(!isChecked);
}
}}
{...props}
>
<span
data-state={isChecked ? 'checked' : 'unchecked'}
className="pointer-events-none block h-5 w-5 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-5"
/>
</button>
);
},
);
Switch.displayName = 'Switch';
export { Switch };
@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<'textarea'>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-28 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea };
@@ -2,7 +2,7 @@
import { useMutation } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
@@ -13,9 +13,11 @@ import { exchangeGitHubCode } from '@/features/auth/api/auth';
export function GitHubOAuthCallback() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const { setUser } = useAuth();
const handledCodeRef = useRef<string | null>(null);
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
const [message, setMessage] = useState<{ tone: 'danger' | 'success'; text: string } | null>(null);
const [message, setMessage] = useState<{ tone: 'danger' | 'success'; text: string } | null>(null);
const code = searchParams?.get('code')?.trim() ?? '';
const mutation = useMutation({
mutationFn: exchangeGitHubCode,
@@ -31,15 +33,19 @@ export function GitHubOAuthCallback() {
});
useEffect(() => {
const code = searchParams?.get('code');
if (!code) {
if (!code) {
setPrompt('缺少授权 code');
setMessage({ tone: 'danger', text: '未收到 GitHub 授权参数,请返回登录页重试。' });
return;
}
mutation.mutate(code);
}, [mutation, searchParams]);
if (handledCodeRef.current === code) {
return;
}
handledCodeRef.current = code;
mutation.mutate(code);
}, [code, mutation]);
return (
<AppCard title='GitHub OAuth 回调' description={prompt}>
@@ -1,128 +1,170 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { useAuth } from '@/components/providers/auth-provider';
import { AppCard } from '@/components/ui/app-card';
import { login } from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const loginSchema = z.object({
username: z.string().min(1, '请输入用户名'),
password: z.string().min(1, '请输入密码'),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const [errorMessage, setErrorMessage] = useState('');
const redirect = searchParams?.get('redirect') || '/';
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: '',
password: '',
},
});
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
setUser(user);
router.replace(redirect);
},
onError: (error: Error) => {
setErrorMessage(error.message || '登录失败,请稍后重试。');
},
});
const handleSubmit = form.handleSubmit((values) => {
setErrorMessage('');
loginMutation.mutate(values);
});
const handleGitHubLogin = () => {
const clientId = statusQuery.data?.github_client_id;
if (!clientId) {
setErrorMessage('GitHub 登录当前不可用。');
return;
}
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('scope', 'user:email');
window.location.href = authorizeUrl.toString();
};
return (
<PublicAuthGuard>
<AppCard title='用户登录'>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label='用户名'>
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='密码'>
<AuthInput type='password' placeholder='请输入密码' {...form.register('password')} />
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
{errorMessage ? <InlineMessage tone='danger' message={errorMessage} /> : null}
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthButton type='submit' disabled={loginMutation.isPending}>
{loginMutation.isPending ? '登录中...' : '登录'}
</AuthButton>
{statusQuery.data?.github_oauth ? (
<SecondaryButton type='button' onClick={handleGitHubLogin} className='w-full sm:w-auto'>
GitHub 登录
</SecondaryButton>
) : null}
</div>
</form>
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
<Link href='/reset' className='text-[var(--brand-primary)] transition hover:opacity-80'>
忘记密码?
</Link>
<span>·</span>
<Link href='/register' className='text-[var(--brand-primary)] transition hover:opacity-80'>
注册
</Link>
</div>
</AppCard>
</PublicAuthGuard>
);
}
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { useAuth } from '@/components/providers/auth-provider';
import { AppCard } from '@/components/ui/app-card';
import { login } from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const TEXT = {
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
loginFailed: '\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
githubUnavailable: 'GitHub \u767b\u5f55\u5f53\u524d\u4e0d\u53ef\u7528\u3002',
title: '\u7528\u6237\u767b\u5f55',
username: '\u7528\u6237\u540d',
password: '\u5bc6\u7801',
loginPending: '\u767b\u5f55\u4e2d...',
login: '\u767b\u5f55',
githubLogin: 'GitHub \u767b\u5f55',
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
register: '\u6ce8\u518c',
};
const loginSchema = z.object({
username: z.string().min(1, TEXT.usernameRequired),
password: z.string().min(1, TEXT.passwordRequired),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const [errorMessage, setErrorMessage] = useState('');
const redirect = searchParams?.get('redirect') || '/';
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: '',
password: '',
},
});
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const canUsePasswordRegister =
(statusQuery.data?.register_enabled ?? false) &&
(statusQuery.data?.password_register_enabled ?? false);
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
setUser(user);
router.replace(redirect);
},
onError: (error: Error) => {
setErrorMessage(error.message || TEXT.loginFailed);
},
});
const handleSubmit = form.handleSubmit((values) => {
setErrorMessage('');
loginMutation.mutate(values);
});
const handleGitHubLogin = () => {
const clientId = statusQuery.data?.github_client_id;
if (!clientId) {
setErrorMessage(TEXT.githubUnavailable);
return;
}
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('scope', 'user:email');
window.location.href = authorizeUrl.toString();
};
return (
<PublicAuthGuard>
<AppCard title={TEXT.title}>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label={TEXT.username}>
<AuthInput
placeholder={TEXT.username}
{...form.register('username')}
/>
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.password}>
<AuthInput
type='password'
placeholder={TEXT.password}
{...form.register('password')}
/>
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
{errorMessage ? (
<InlineMessage tone='danger' message={errorMessage} />
) : null}
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthButton type='submit' disabled={loginMutation.isPending}>
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
</AuthButton>
{statusQuery.data?.github_oauth ? (
<SecondaryButton
type='button'
onClick={handleGitHubLogin}
className='w-full sm:w-auto'
>
{TEXT.githubLogin}
</SecondaryButton>
) : null}
</div>
</form>
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
<Link
href='/reset'
className='text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.forgotPassword}
</Link>
{canUsePasswordRegister ? (
<>
<span>|</span>
<Link
href='/register'
className='text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.register}
</Link>
</>
) : null}
</div>
</AppCard>
</PublicAuthGuard>
);
}
@@ -1,225 +1,332 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { AppCard } from '@/components/ui/app-card';
import {
register as registerUser,
sendEmailVerification,
} from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const baseSchemaObject = z.object({
username: z.string().min(1, '请输入用户名').max(12, '用户名最长 12 位'),
password: z.string().min(8, '密码至少 8 位').max(20, '密码最长 20 位'),
password2: z.string().min(8, '请再次输入密码'),
email: z.string().optional(),
verification_code: z.string().optional(),
});
const baseSchema = baseSchemaObject.refine((data) => data.password === data.password2, {
message: '两次输入的密码不一致',
path: ['password2'],
});
type RegisterFormValues = z.infer<typeof baseSchema>;
export function RegisterForm() {
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState('');
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null);
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const needsEmailVerification = statusQuery.data?.email_verification ?? false;
const needsTurnstile = statusQuery.data?.turnstile_check ?? false;
const schema = useMemo(() => {
if (!needsEmailVerification) {
return baseSchema;
}
return baseSchemaObject
.extend({
email: z.string().email('请输入有效邮箱地址'),
verification_code: z.string().min(1, '请输入验证码'),
})
.refine((data) => data.password === data.password2, {
message: '两次输入的密码不一致',
path: ['password2'],
});
}, [needsEmailVerification]);
const form = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: {
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
},
});
const registerMutation = useMutation({
mutationFn: (values: RegisterFormValues) =>
registerUser(
{
username: values.username,
password: values.password,
email: values.email,
verification_code: values.verification_code,
},
turnstileToken || undefined,
),
onSuccess: () => {
router.replace('/login');
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '注册失败,请稍后重试。' });
},
});
const verificationMutation = useMutation({
mutationFn: async () => {
const email = form.getValues('email');
if (!email) {
form.setError('email', { message: '请输入邮箱地址' });
return;
}
await sendEmailVerification(email, turnstileToken || undefined);
},
onSuccess: () => {
setMessage({ tone: 'success', text: '验证码发送成功,请检查邮箱。' });
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '验证码发送失败,请稍后重试。' });
},
});
const handleSubmit = form.handleSubmit((values) => {
setMessage(null);
if (needsTurnstile && !turnstileToken) {
setMessage({ tone: 'info', text: '请先完成人机验证。' });
return;
}
registerMutation.mutate(values);
});
return (
<PublicAuthGuard>
<AppCard title='新用户注册' description='兼容现有密码注册链路,后续可继续扩展第三方注册。'>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label='用户名' hint='最长 12 位'>
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='密码' hint='最短 8 位,最长 20 位'>
<AuthInput type='password' placeholder='请输入密码' {...form.register('password')} />
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='确认密码'>
<AuthInput type='password' placeholder='请再次输入密码' {...form.register('password2')} />
{form.formState.errors.password2 ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password2.message}
</span>
) : null}
</AuthFormField>
{needsEmailVerification ? (
<>
<AuthFormField label='邮箱地址'>
<AuthInput type='email' placeholder='请输入邮箱地址' {...form.register('email')} />
{form.formState.errors.email ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.email.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='邮箱验证码'>
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthInput
placeholder='请输入验证码'
className='flex-1'
{...form.register('verification_code')}
/>
<SecondaryButton
type='button'
onClick={() => {
if (needsTurnstile && !turnstileToken) {
setMessage({ tone: 'info', text: '请先完成人机验证。' });
return;
}
setMessage(null);
verificationMutation.mutate();
}}
disabled={verificationMutation.isPending}
>
{verificationMutation.isPending ? '发送中...' : '获取验证码'}
</SecondaryButton>
</div>
{form.formState.errors.verification_code ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.verification_code.message}
</span>
) : null}
</AuthFormField>
</>
) : null}
{needsTurnstile && statusQuery.data?.turnstile_site_key ? (
<TurnstileWidget
siteKey={statusQuery.data.turnstile_site_key}
onVerify={(token) => setTurnstileToken(token)}
onExpire={() => setTurnstileToken('')}
onError={() => setTurnstileToken('')}
/>
) : null}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
<AuthButton type='submit' disabled={registerMutation.isPending}>
{registerMutation.isPending ? '注册中...' : '注册'}
</AuthButton>
</form>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
已有账户?
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'>
点击登录
</Link>
</div>
</AppCard>
</PublicAuthGuard>
);
}
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { AppCard } from '@/components/ui/app-card';
import {
register as registerUser,
sendEmailVerification,
} from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const TEXT = {
title: '\u65b0\u7528\u6237\u6ce8\u518c',
description:
'\u517c\u5bb9\u73b0\u6709\u5bc6\u7801\u6ce8\u518c\u94fe\u8def\uff0c\u540e\u7eed\u53ef\u7ee7\u7eed\u6269\u5c55\u7b2c\u4e09\u65b9\u6ce8\u518c\u3002',
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
usernameTooLong: '\u7528\u6237\u540d\u6700\u957f 12 \u4f4d',
passwordTooShort: '\u5bc6\u7801\u81f3\u5c11 8 \u4f4d',
passwordTooLong: '\u5bc6\u7801\u6700\u957f 20 \u4f4d',
passwordRepeatRequired: '\u8bf7\u518d\u6b21\u8f93\u5165\u5bc6\u7801',
passwordMismatch: '\u4e24\u6b21\u8f93\u5165\u7684\u5bc6\u7801\u4e0d\u4e00\u81f4',
emailInvalid: '\u8bf7\u8f93\u5165\u6709\u6548\u90ae\u7bb1\u5730\u5740',
codeRequired: '\u8bf7\u8f93\u5165\u9a8c\u8bc1\u7801',
registerFailed: '\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
emailRequired: '\u8bf7\u8f93\u5165\u90ae\u7bb1\u5730\u5740',
verificationSent:
'\u9a8c\u8bc1\u7801\u53d1\u9001\u6210\u529f\uff0c\u8bf7\u68c0\u67e5\u90ae\u7bb1\u3002',
verificationFailed:
'\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
turnstileRequired: '\u8bf7\u5148\u5b8c\u6210\u4eba\u673a\u9a8c\u8bc1\u3002',
registerClosed:
'\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u65b0\u7528\u6237\u6ce8\u518c\u3002',
passwordRegisterClosed:
'\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u5bc6\u7801\u6ce8\u518c\uff0c\u8bf7\u4f7f\u7528\u7b2c\u4e09\u65b9\u767b\u5f55\u5165\u53e3\u5b8c\u6210\u6ce8\u518c\u3002',
hasAccount: '\u5df2\u6709\u8d26\u53f7\uff1f',
backToLogin:
'\u8fd4\u56de\u767b\u5f55\u9875\u67e5\u770b\u53ef\u7528\u5165\u53e3\uff1a',
clickLogin: '\u70b9\u51fb\u767b\u5f55',
username: '\u7528\u6237\u540d',
usernameHint: '\u6700\u957f 12 \u4f4d',
password: '\u5bc6\u7801',
passwordHint: '\u6700\u77ed 8 \u4f4d\uff0c\u6700\u957f 20 \u4f4d',
passwordConfirm: '\u786e\u8ba4\u5bc6\u7801',
email: '\u90ae\u7bb1\u5730\u5740',
emailCode: '\u90ae\u7bb1\u9a8c\u8bc1\u7801',
getCode: '\u83b7\u53d6\u9a8c\u8bc1\u7801',
gettingCode: '\u53d1\u9001\u4e2d...',
register: '\u6ce8\u518c',
registering: '\u6ce8\u518c\u4e2d...',
};
const baseSchemaObject = z.object({
username: z.string().min(1, TEXT.usernameRequired).max(12, TEXT.usernameTooLong),
password: z.string().min(8, TEXT.passwordTooShort).max(20, TEXT.passwordTooLong),
password2: z.string().min(8, TEXT.passwordRepeatRequired),
email: z.string().optional(),
verification_code: z.string().optional(),
});
const baseSchema = baseSchemaObject.refine(
(data) => data.password === data.password2,
{
message: TEXT.passwordMismatch,
path: ['password2'],
},
);
type RegisterFormValues = z.infer<typeof baseSchema>;
export function RegisterForm() {
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState('');
const [message, setMessage] = useState<{
tone: 'success' | 'danger' | 'info';
text: string;
} | null>(null);
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const needsEmailVerification = statusQuery.data?.email_verification ?? false;
const needsTurnstile = statusQuery.data?.turnstile_check ?? false;
const registerEnabled = statusQuery.data?.register_enabled ?? false;
const passwordRegisterEnabled =
statusQuery.data?.password_register_enabled ?? false;
const schema = useMemo(() => {
if (!needsEmailVerification) {
return baseSchema;
}
return baseSchemaObject
.extend({
email: z.string().email(TEXT.emailInvalid),
verification_code: z.string().min(1, TEXT.codeRequired),
})
.refine((data) => data.password === data.password2, {
message: TEXT.passwordMismatch,
path: ['password2'],
});
}, [needsEmailVerification]);
const form = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: {
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
},
});
const registerMutation = useMutation({
mutationFn: (values: RegisterFormValues) =>
registerUser(
{
username: values.username,
password: values.password,
email: values.email,
verification_code: values.verification_code,
},
turnstileToken || undefined,
),
onSuccess: () => {
router.replace('/login');
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || TEXT.registerFailed });
},
});
const verificationMutation = useMutation({
mutationFn: async () => {
const email = form.getValues('email');
if (!email) {
form.setError('email', { message: TEXT.emailRequired });
return;
}
await sendEmailVerification(email, turnstileToken || undefined);
},
onSuccess: () => {
setMessage({ tone: 'success', text: TEXT.verificationSent });
},
onError: (error: Error) => {
setMessage({
tone: 'danger',
text: error.message || TEXT.verificationFailed,
});
},
});
const handleSubmit = form.handleSubmit((values) => {
setMessage(null);
if (needsTurnstile && !turnstileToken) {
setMessage({ tone: 'info', text: TEXT.turnstileRequired });
return;
}
registerMutation.mutate(values);
});
return (
<PublicAuthGuard>
<AppCard title={TEXT.title} description={TEXT.description}>
{!registerEnabled ? (
<div className='space-y-4'>
<InlineMessage tone='info' message={TEXT.registerClosed} />
<div className='text-sm text-[var(--foreground-secondary)]'>
{TEXT.hasAccount}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</div>
) : !passwordRegisterEnabled ? (
<div className='space-y-4'>
<InlineMessage tone='info' message={TEXT.passwordRegisterClosed} />
<div className='text-sm text-[var(--foreground-secondary)]'>
{TEXT.backToLogin}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</div>
) : (
<>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label={TEXT.username} hint={TEXT.usernameHint}>
<AuthInput
placeholder={TEXT.username}
{...form.register('username')}
/>
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.password} hint={TEXT.passwordHint}>
<AuthInput
type='password'
placeholder={TEXT.password}
{...form.register('password')}
/>
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.passwordConfirm}>
<AuthInput
type='password'
placeholder={TEXT.passwordConfirm}
{...form.register('password2')}
/>
{form.formState.errors.password2 ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password2.message}
</span>
) : null}
</AuthFormField>
{needsEmailVerification ? (
<>
<AuthFormField label={TEXT.email}>
<AuthInput
type='email'
placeholder={TEXT.email}
{...form.register('email')}
/>
{form.formState.errors.email ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.email.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.emailCode}>
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthInput
placeholder={TEXT.emailCode}
className='flex-1'
{...form.register('verification_code')}
/>
<SecondaryButton
type='button'
onClick={() => {
if (needsTurnstile && !turnstileToken) {
setMessage({
tone: 'info',
text: TEXT.turnstileRequired,
});
return;
}
setMessage(null);
verificationMutation.mutate();
}}
disabled={verificationMutation.isPending}
>
{verificationMutation.isPending
? TEXT.gettingCode
: TEXT.getCode}
</SecondaryButton>
</div>
{form.formState.errors.verification_code ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.verification_code.message}
</span>
) : null}
</AuthFormField>
</>
) : null}
{needsTurnstile && statusQuery.data?.turnstile_site_key ? (
<TurnstileWidget
siteKey={statusQuery.data.turnstile_site_key}
onVerify={(token) => setTurnstileToken(token)}
onExpire={() => setTurnstileToken('')}
onError={() => setTurnstileToken('')}
/>
) : null}
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
<AuthButton type='submit' disabled={registerMutation.isPending}>
{registerMutation.isPending ? TEXT.registering : TEXT.register}
</AuthButton>
</form>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
{TEXT.hasAccount}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</>
)}
</AppCard>
</PublicAuthGuard>
);
}
@@ -300,9 +300,6 @@ function PublishPreviewCard({
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Pending Main Config
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
{`Checksum: ${preview.checksum}`}
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.main_config}
@@ -27,16 +27,22 @@ export interface ConfigPreviewResult {
support_files: SupportFile[];
checksum: string;
route_count: number;
website_count: number;
}
export interface ConfigDiffResult {
active_version?: string;
added_sites: string[];
removed_sites: string[];
modified_sites: string[];
added_domains: string[];
removed_domains: string[];
modified_domains: string[];
main_config_changed: boolean;
changed_option_keys: string[];
changed_option_details: ConfigOptionDiffItem[];
current_website_count: number;
active_website_count: number;
}
export interface ConfigOptionDiffItem {
+11 -10
View File
@@ -183,16 +183,17 @@ export interface NodeObservabilityTrends {
disk_io_24h: NodeDiskIOTrendPoint[];
}
export interface NodeHealthEvent {
event_type: string;
severity: string;
status: string;
message: string;
first_triggered_at: string;
last_triggered_at: string;
reported_at: string;
resolved_at?: string | null;
}
export interface NodeHealthEvent {
event_type: string;
severity: string;
status: string;
message: string;
metadata_json?: string;
first_triggered_at: string;
last_triggered_at: string;
reported_at: string;
resolved_at?: string | null;
}
export interface NodeObservability {
node_id: string;

Some files were not shown because too many files have changed in this diff Show More