Compare commits

..

14 Commits

Author SHA1 Message Date
ryan 68d70bbbe8 [新增] 界面优化 2026-06-03 19:53:54 +08:00
ryan 08ec945e59 [新增] 界面优化 2026-06-03 19:52:38 +08:00
ryan 4401cb0d66 [新增] POW 与 WAF 规则合并 2026-06-03 19:09:01 +08:00
ryan 36ae6247f9 [新增] OpenFlare Pages 2026-06-03 17:48:01 +08:00
ryan 1088086399 [新增] OpenFlare Pages 2026-06-03 17:31:14 +08:00
ryan 2c74d042ed [新增] OpenFlare Pages 2026-06-03 17:09:02 +08:00
ryan 3ec607106d [新增] 对接 Uptime Kuma 2026-06-03 12:17:46 +08:00
ryan f671a96d8c [新增] 对接 Uptime Kuma 2026-06-03 11:58:00 +08:00
ryan fe5cf9021f [优化] 更新Dockerfile,优化构建过程 2026-06-02 23:49:17 +08:00
ryan 1be2461716 [优化] 修复构建 2026-06-02 23:43:10 +08:00
ryan a0e9484e37 [优化] 重构数据库迁移逻辑,整合遗留和Goose迁移处理 2026-06-02 23:35:02 +08:00
ryan 4ca6f2957b [优化] 重构数据库迁移逻辑,整合遗留和Goose迁移处理 2026-06-02 22:27:04 +08:00
ryan dfd040a9de [优化] 确保所有管理的进程在停止时被正确取消和清理 2026-06-02 21:30:48 +08:00
ryan f29292dd81 [优化] 添加进程管理功能,支持PID文件处理和孤儿进程清理 2026-06-02 21:08:04 +08:00
89 changed files with 6676 additions and 504 deletions
+8
View File
@@ -1,5 +1,6 @@
.git
.idea
.github
anubis-source
**/node_modules
**/.next
@@ -9,3 +10,10 @@ anubis-source
**/coverage
**/*.db
**/*.log
tmp
logs
.DS_Store
.env
.env.*
docker-compose*.yml
+1
View File
@@ -50,3 +50,4 @@ go.work.sum
*-source
*-source.*
.codex*
+4 -3
View File
@@ -98,10 +98,11 @@ Agent 对数据面 OpenResty 的管控实现了端到端的闭环,包含配置
* `certs/`:证书存放目录(文件命名为 `{cert_id}.crt` 和 `{cert_id}.key`)。
* `waf/` 与 `pow/`:WAF 及防 CC 挑战所需的专用 Lua 运行时脚本。
* `waf_config.json` 与 `waf_ip_groups.json`:WAF 过滤引擎所需的结构化规则配置文件。
* `pages_dir`:Pages 静态站点部署目录,默认位于 `data_dir/var/lib/openflare/pages`。当激活配置引用 Pages 部署时,Agent 会下载部署 zip、校验 checksum、解压到部署 release 目录,并切换 `deployments/{deployment_id}/current` 供 OpenResty `root`/`try_files` 读取。
### 2. 精细化的重载动作
1. **备份当前配置**:在写入新文件之前,Agent 会将现有的配置文件复制到 `.backup` 临时目录下,保留完整的现场快照。
2. **写入并替换占位符**:将最新拉取的模板写入,自动将模板中的绝对路径占位符(如 `__OPENFLARE_LUA_DIR__`)替换为本地实际运行路径。
2. **写入并替换占位符**:将最新拉取的模板写入,自动将模板中的绝对路径占位符(如 `__OPENFLARE_LUA_DIR__`、`__OPENFLARE_PAGES_DIR__`)替换为本地实际运行路径。
3. **语法校验**:调用 `openresty -t -c <temp_nginx.conf>` 进行严格的语法测试。
4. **平滑重载**:若校验通过,将新配置移至正式路径,执行 `openresty -s reload`。若 OpenResty 处于未启动状态,则使用当前配置拉起进程。
5. **捕获异常**:校验或重载失败时,Agent 会截获标准错误输出(stderr),提取前 2000 个字符的详细报错信息。
@@ -117,7 +118,7 @@ OpenFlare 摒弃了动态 Patch 节点配置的落后方式,采用 **不可变
```
### 1. 核心设计原则
* **完整发布**:每次发布均是对当前控制面所有启用路由、证书、全局与局部 WAF 规则进行一次性全量编译,生成带唯一 `checksum` 的完整版本。
* **完整发布**:每次发布均是对当前控制面所有启用路由、证书、Pages 部署引用、全局与局部 WAF 规则进行一次性全量编译,生成带唯一 `checksum` 的完整版本。
* **版本格式**:采用 `YYYYMMDD-NNN` 递增格式,确保版本历史直观、具备单调递增性。
* **全局单激活版本**:系统同时只有一个处于 `active` 状态的全局配置版本。回滚时无需逆向打补丁,只需将历史某个健康版本的状态改为 `active`,Agent 重新拉取应用即可。
@@ -171,4 +172,4 @@ graph TD
1. **零特权指令通道**:Server 绝对禁止向 Agent 传递任何任意 shell 命令或远程执行脚本(如 exec/eval 等)。所有系统控制原语(如启动、停止、重载、更新)必须硬编码在 Agent 二进制内部。
2. **严格的 Token 过滤与前缀验证**:Agent 侧向 Server 请求资源时,接口端点固定以 `/api/agent/` 为前缀,并强制携带 `X-Agent-Token` 进行签名或令牌核验。
3. **节点自治原则**:Agent 须具备完备的离线工作能力。在与 Server 失去连接期间,本地 OpenResty 必须依靠本地已落地的配置保持反向代理服务的绝对正常运行。
3. **节点自治原则**:Agent 须具备完备的离线工作能力。在与 Server 失去连接期间,本地 OpenResty 必须依靠本地已落地的配置保持反向代理服务的绝对正常运行。
+24 -3
View File
@@ -48,13 +48,27 @@ OpenFlared (frpc) <-- 内网服务器
Internal Service (192.168.x.x)
```
### Pages 静态托管流量路径
```text
Browser
|
| HTTPS request
v
OpenResty (Agent, TLS/WAF)
|
| root/try_files
v
Agent 本地 Pages 部署目录
```
## 组件职责
| 组件 | 职责 |
| --------------- | ---------------------------------------------------------------------- |
| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、数据存储与聚合查询 |
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、Pages 部署包存储、数据存储与聚合查询 |
| Agent | 注册、心跳、同步、写入文件、Pages 部署包拉取与解压、校验、reload、失败回滚、自更新与轻量采集 |
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证、反向代理与 Pages 静态文件服务 |
| OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 |
| OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 |
| Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 |
@@ -71,6 +85,8 @@ Internal Service (192.168.x.x)
Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存控制面状态、生成完整配置版本,并通过 Agent API 让节点主动拉取。
Pages 静态托管场景中,Server 保存 Pages 项目、SPA fallback 回退路径、不可变部署元数据、文件清单和 zip 部署包;发布版本只记录部署引用、checksum 与静态渲染策略,不把大体积静态资源写入 `config_versions`。
## Agent
`openflare_agent` 是 Go 单体程序:
@@ -79,6 +95,7 @@ Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存
* 启动后读取或生成本地节点信息。
* 周期性 heartbeat,上报状态并获取激活版本摘要。
* 发现新版本后拉取配置、备份旧文件、写入新文件、校验并 reload。
* 当激活配置引用 Pages 部署时,先按部署 ID 下载 zip 包,校验 checksum,解压到本地 `pages_dir` 并切换当前部署目录。
* 应用失败时尝试恢复运行并回滚。
* 维护 WAF GeoIP mmdb,启动时写入内置初始库,并按配置定期更新。
@@ -118,6 +135,7 @@ Browser -> Frontend -> /api/* -> controller -> service -> model -> database
```text
Agent HTTP heartbeat -> Server 返回激活版本摘要
Agent 发现新版本 -> 拉取配置详情
Agent 确保 Pages 部署包已下载、校验并解压 (如配置引用 Pages)
Agent 写入主配置 / 路由配置 / 证书 / Lua 资源 / WAF 运行时配置
Agent 执行 OpenResty 校验与 reload
Agent 上报应用结果
@@ -180,6 +198,9 @@ WAF IP 组由 Server 管理。手动 IP 组直接保存 IP/IP 段列表;自动
* `proxy_routes`
* `origins`
* `config_versions`
* `pages_projects`
* `pages_deployments`
* `pages_deployment_files`
* `nodes`
* `tunnels`
* `auth_sources`
+1 -1
View File
@@ -179,4 +179,4 @@ go build -o openflare-agent ./cmd/agent
4. 涉及配置、部署、API 或产品边界时同步更新文档。
5. 风险较高的修改补充测试或等效联调验证。
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
数据库结构变更必须提升数据库版本号,并补充显式迁移方法和校验逻辑。v8-v17 保留为旧升级框架兼容链;v17 之后统一使用 goose,新的 goose 框架代码必须集中在 `openflare_server/model/goose` 包下;每次数据库升级都要在该包下新增独立的 `goose_<timestamp>_<description>.go` 文件,不得把具体迁移逻辑集中堆在 goose 注册入口中,也不得把新 goose 框架代码放回 `openflare_server/model` 根包。
+17 -1
View File
@@ -32,6 +32,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
| 管理端前端 | 基于 Next.js 的正式管理端 |
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
| 内网穿透 | 通过 TunnelRelay 节点与 OpenFlared 客户端,将内网 HTTP 服务安全暴露到公网,复用 Agent 的 HTTPS/WAF 能力 |
| Pages 静态托管 | 以 Pages 项目管理静态站点部署包,发布后由边缘 Agent 拉取并在本地 OpenResty 静态服务 |
默认工作方式:
@@ -52,6 +53,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
| 证书托管 | 为不同域名绑定 TLS 证书 |
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
| 内网穿透 | 通过 Tunnel 将无法直接公网访问的内网 HTTP 服务暴露到互联网,享有 HTTPS、WAF 等全部防护能力 |
| 静态站点托管 | 上传已构建的静态资源包,将网站规则上游绑定到 Pages 项目,在边缘节点本地服务静态文件 |
## 网站配置约束
@@ -72,12 +74,26 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
上游约束:
* `proxy_routes` 至少包含一个上游地址(直连类型 `direct`),或关联一个 Tunnel(内网穿透类型 `tunnel`)。
* `proxy_routes` 至少包含一个上游地址(直连类型 `direct`),或关联一个 Tunnel(内网穿透类型 `tunnel`),或关联一个 Pages 项目(静态托管类型 `pages`)。
* 多上游负载均衡统一渲染为带 keepalive 的 named `upstream`。
* 单上游允许附带 base path 或 query,并在 `proxy_pass` 中追加。多上游限定为纯 `scheme://host[:port]` 结构,且同一规则内的协议必须一致。
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头。
* 所有直连类型上游地址都必须为合法的 `http://` 或 `https://`。
* 内网穿透类型上游必须关联有效 `tunnel_id`,并指定内网目标地址与协议。
* Pages 类型上游必须关联有效 Pages 项目,且项目必须存在已激活部署。Pages 站点不执行服务端构建、边缘函数或动态运行时代码,仅托管预构建静态资源。
## Pages 静态托管约束
OpenFlare Pages 面向边缘节点静态站点托管,采用“项目 + 不可变部署 + 网站规则绑定”的模型。
约束:
* Pages 项目保存名称、标识、启用状态、SPA fallback 启用状态、自定义回退路径和当前激活部署。
* Pages 部署由管理端上传预构建 zip 包生成;部署包保存在 Server 本地 Pages 存储目录,数据库只保存部署元数据和文件清单,不保存大体积文件内容。
* 只有项目存在激活部署后,`proxy_routes.upstream_type = 'pages'` 的网站规则才能绑定该项目。
* Pages 网站继续复用网站规则的域名、HTTPS、WAF、PoW、Basic Auth、限流、缓存配置和配置版本发布机制。
* 发布快照保存 Pages 项目、部署 ID、部署 checksum、入口文件、SPA fallback 启用状态和回退路径。Agent 拉取激活配置时按部署 checksum 下载并校验部署包,解压到本地 `pages_dir` 后再应用 OpenResty 配置。
* V1 不支持 Git 自动构建、预览域名、边缘函数、动态 SSR、外部对象存储或多租户隔离。
## 内网穿透约束
+15
View File
@@ -63,6 +63,21 @@ OpenFlare 不直接在线修改节点上的 Nginx/OpenResty 配置。你在管
* 修改源站目录后,检查已发布的网站配置是否需要同步更新源站快照。
* 发布前使用预览或 diff 确认渲染结果。
## 托管 Pages 静态站点
Pages 用于托管已经构建完成的静态资源包。当前阶段只支持 Direct Upload,不执行 Git 构建、边缘函数或 SSR。
操作顺序:
1. 进入 **Pages** 页面,点击 **新建 Pages 项目**。
2. 填写项目名称、标识、描述;如为前端 history 路由应用,启用 **SPA fallback** 并填写回退路径,默认是 `/index.html`,也可以设置为 `/app.html` 等站点内绝对路径。
3. 创建后回到 Pages 项目列表,点击项目进入详情。
4. 在项目详情中上传 zip 静态资源包,并激活某个部署。
5. 新建或编辑网站规则,将回源方式切换为 **Pages 静态站点**,选择该 Pages 项目。
6. 发布并激活配置版本,Agent 会下载部署包、校验 checksum、解压到本地 Pages 目录,再由 OpenResty 本地服务静态文件。
Pages 项目只有在启用且存在激活部署后,才会出现在网站规则的 Pages 项目选择列表中。
## 启用 HTTPS
HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
+29 -8
View File
@@ -49,7 +49,9 @@ Frontend:
各组件和模块(Server、Agent、Frontend)的物理目录分层职责详见 [仓库结构](../design/repository.md)。在此结构下,开发必须遵守以下核心分层规则:
* **Server 开发规则**:禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
* **Server 开发规则**:
* 禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
* **定时任务开发规则**:禁止将不同业务模块(如 Uptime Kuma 整合、WAF IP 同步等)的定时任务具体执行逻辑与状态堆积在单个 `cron.go` 文件中。各模块对应的定时任务结构体和运行逻辑必须在独立的 Go 文件中定义,`cron.go` 只允许承担统一注册、初始化与调度器启停的职责。
* **Agent 开发规则**:每个模块职责单一,外部命令调用集中封装,状态落盘与配置落盘分离。
* **Frontend 开发规则**:页面文件只负责获取路由参数、组织页面结构、调用 feature 组件;不应手写复杂 API 细节、复杂表单校验逻辑或维护大量彼此耦合的局部状态。
@@ -59,6 +61,7 @@ Frontend:
### 1. 当前有效实体
* **核心配置与反代**:`proxy_routes` (网站配置), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书), `managed_domains` (托管域名).
* **Pages 静态托管**:`pages_projects` (Pages 项目), `pages_deployments` (不可变部署), `pages_deployment_files` (部署文件清单).
* **节点与状态**:`nodes` (节点), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
* **内网穿透**:`tunnels` (隧道客户端), `tunnel_tokens` (隧道认证令牌,可选持久化).
* **观测与分析**:`node_request_reports` (请求上报), `node_access_logs` (访问明细), `node_metric_snapshots` (指标快照), `traffic_analytics_rollups` (流量聚合), `node_health_events` (健康事件).
@@ -92,6 +95,13 @@ Frontend:
* 必须指定 `tunnel_target_addr`(内网目标地址,如 `192.168.1.100:8080`)和 `tunnel_target_protocol`(`http` 或 `https`)。
* 发布配置时,Server 自动将此上游渲染为 `http://127.0.0.1:{relay_vhost_port}`,Agent 依据 Host 头由 frps 路由。
* **Pages 与上游关联**:
* `proxy_routes.upstream_type = 'pages'` 时,必须指定 `pages_project_id`。
* 被引用的 Pages 项目必须启用,且必须存在当前激活部署。
* Pages 项目可启用 SPA fallback 并配置站点内绝对回退路径(默认 `/index.html`);回退路径必须经 Server 校验后进入发布快照,不得直接拼接未校验输入到 OpenResty 配置。
* Pages 部署包必须作为 Server 本地文件保存,数据库只保存部署元数据与文件清单;禁止把静态资源内容写入 `config_versions.support_files_json`。
* 发布配置时,Server 将 Pages 上游渲染为 OpenResty `root` + `try_files` 静态服务,并保留网站规则已有的 HTTPS、WAF、PoW、Basic Auth、限流与缓存配置。
* **TunnelRelay 节点配置**:
* `nodes.node_type = 'tunnel_relay'` 时,新增字段 `relay_bind_port`、`relay_vhost_http_port`、`relay_auth_token` 必须有合理默认值。
* `relay_bind_port` 默认 7000,`relay_vhost_http_port` 默认 8080。
@@ -112,17 +122,19 @@ Frontend:
每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法。迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录。
v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起,数据库迁移必须放在 `openflare_server/model/migrate` 目录中,并以目标版本命名文件,例如 `v16.go`。每个版本文件通过 `init()` 注册自己的迁移,当前数据库版本取已注册迁移的最大目标版本。不得为了整理文件而改变已发布 v8+ 迁移的语义。
v1-v7 视为历史初始基线,不再维护逐版本升级文件。v8-v17 是旧升级框架的兼容迁移链,只保留在 `openflare_server/model/migrate` 目录中用于老库升级。旧库启动时必须先按旧框架升级到 `legacyMigrationTerminalVersion`,再桥接到 goose;不得为了整理文件而改变已发布 v8-v17 迁移的语义。
从 v17 之后,数据库升级统一使用 goose。新的 goose provider、桥接逻辑、注册入口和具体迁移文件必须全部放在 `openflare_server/model/goose` 包下,`openflare_server/model` 根包只保留纯净实体类、旧框架兼容适配和必要的上下文注入。每次新增数据库升级都必须新建一个单独的 Go 文件,文件名使用 `openflare_server/model/goose/goose_<timestamp>_<description>.go`,例如 `openflare_server/model/goose/goose_202606020001_add_node_capabilities_json.go`。迁移文件必须同时包含该版本的 goose migration 构造函数、升级逻辑和校验逻辑;`model/goose/migrations.go` 只能作为注册入口和公共构造工具,禁止把具体迁移逻辑集中堆放在该文件中。
执行数据库升级时必须按以下步骤完成:
1. 判断是否需要升级数据库版本:凡是新增/删除/重命名表、字段、索引、约束、列类型、分表规则,或改变持久化数据语义,都必须升级。
2. 新增 `openflare_server/model/migrate/vN.go`,其中 `N` 为目标版本号。文件头部必须包含注释,说明本次升级了什么内容,以及为什么需要升级。
3. 在 `vN.go` 中实现 `VN()`,并在 `init()` 中调用 `Register(VN())`。`FromVersion` 必须等于 `N-1`,`ToVersion` 必须等于 `N`。
4. 在 `migrateVN` 中写入升级逻辑。可通过 `Context` 调用 `ApplyCurrentSchema`、历史 backfill、默认数据初始化等公共能力;复杂数据修复必须显式处理,不得只依赖 `AutoMigrate`。
5. 在 `validateVN` 中写入升级后的校验逻辑。校验至少要覆盖新增表/字段/索引是否存在、关键默认数据是否存在、必要的数据回填是否成功。
6. 如果新迁移需要新的公共 backfill 或校验辅助函数,将其放在 `openflare_server/model/migrations.go` 或更合适的 model 文件中,并通过 `Context` 暴露给 `model/migrate`,避免子包反向 import `model` 造成循环依赖。
7. 补充迁移测试:至少覆盖从 `N-1` 老库升级到 `N` 后 schema version、字段/表结构、关键数据回填和校验结果。注册表连续性由 `model/migrate` 测试兜底,但具体业务迁移仍必须有测试。
2. 新增 `openflare_server/model/goose/goose_<timestamp>_<description>.go`,其中 `<timestamp>` 为 goose 版本号。文件头部或迁移构造函数附近必须包含注释,说明本次升级了什么内容,以及为什么需要升级。
3. 在该文件中实现独立迁移构造函数,并返回通过 `newGORMMigration(...)` 创建的 migration;随后只在 `openflare_server/model/goose/migrations.go` 的 `registeredMigrations(...)` 中新增一条注册项。
4. 在同一个单独迁移文件中写入升级逻辑。可通过 goose `Context` 调用 `ApplyCurrentSchema`、历史 backfill、默认数据初始化等公共能力;复杂数据修复必须显式处理,不得只依赖 `AutoMigrate`。
5. 在同一个单独迁移文件中写入升级后的校验逻辑。校验至少要覆盖新增表/字段/索引是否存在、关键默认数据是否存在、必要的数据回填是否成功。
6. 如果新迁移需要新的公共 backfill 或校验辅助函数,优先放在该迁移文件中;只有多个迁移共同复用时,才放到 `openflare_server/model/goose` 包内的公共文件中。不要把新 goose 框架代码放回 `openflare_server/model` 根包。
7. 补充迁移测试:至少覆盖从旧框架终点或上一 goose 版本升级后 schema version、字段/表结构、关键数据回填和校验结果。还应保留旧库从 v15/v17 桥接到 goose 的回归覆盖。
8. 同步更新设计/开发文档;如果管理端 API、配置项或用户可见行为变化,还要同步更新对应指南、配置参考和 Swagger 文档。
新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本。
@@ -159,6 +171,11 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起
- CRUD tunnel 实体(创建、查询、更新、删除)。
- Token 管理(生成、轮换)。
- 强制同步(触发 Client 立即拉取新配置)。
* **Admin Pages 管理 API** - `/api/pages/*`,要求 Admin Session。
- CRUD Pages 项目,包括 SPA fallback 启用状态与回退路径。
- 上传 zip 部署包、查看部署历史、激活部署、删除非激活部署。
* **Agent Pages 下载 API** - `/api/agent/pages/*`,使用 `X-Agent-Token` 认证。
- Agent 仅能按激活配置引用的部署 ID 拉取静态部署包,不提供任意文件读取或远程命令入口。
* 总览与节点详情优先使用专用聚合接口。
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
* 管理端继续复用现有登录、角色与 Session。
@@ -180,6 +197,9 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起
* 读取 WAF 规则组、规则组引用的 IP 组与网站绑定关系,并在发布快照中保存可回放数据。
* 自动型 WAF IP 组只能由 Server 定时任务读取请求日志并执行 Expr 布尔规则,OpenResty Lua 与 Agent 不得直接访问请求日志库或执行自动挖掘逻辑。
* 发布版本不得展开 WAF IP 组成员;Agent 必须通过独立的 IP 组 checksum 差异同步和 WebSocket 增量广播维护本地 `waf_ip_groups.json`。
* **Pages 配置扩展**:区分上游类型,为 `upstream_type = 'pages'` 的代理规则生成 Pages 部署快照。
* OpenResty 侧:将 Pages 上游渲染为本地静态目录 `root` 与 `try_files`,启用 SPA fallback 时使用项目配置的回退路径,不得渲染 `proxy_pass`。
* Agent 侧:在应用 OpenResty 配置前,必须确保引用的 Pages 部署包已下载、checksum 校验通过并解压到 `pages_dir`。
* **内网穿透配置扩展**:区分上游类型,为 `upstream_type = 'tunnel'` 的代理规则生成独立的 tunnel 配置数据。
* OpenResty 侧:将 tunnel 上游自动渲染为 `http://127.0.0.1:{relay_vhost_port}`,必须保留原始 `Host` 请求头。
* Tunnel 侧:为每个 Client 生成完整的 relay 列表与 frpc 代理定义(frpc proxy 配置)。
@@ -205,6 +225,7 @@ Agent 必须满足:
* 发现新版本时先备份旧文件。
* 写入主配置、路由配置与必要证书文件。
* 写入 WAF/PoW 运行时配置,并确保 WAF Lua 资源由 Agent 统一管理。
* 如果激活配置引用 Pages 部署,必须通过 Agent API 拉取部署包,校验配置快照中的 SHA-256 checksum,安全解压并原子切换本地当前部署目录;zip 路径不得逃逸 `pages_dir`,不得接受符号链接。
* WAF IP 组同步必须按组增量更新,不得在每次心跳或每次同步中传输全部 IP 组。
* 写入新配置后执行 `openresty -t -c <main_config_path>`,再 reload;reload 发现运行时未启动时允许直接启动 OpenResty。
* 周期性运行时健康检查不得调用 `openresty -t`,避免健康探针触发 upstream 域名同步解析;应优先请求本地 `openresty_observability_port` 上的 `/openflare/stub_status`,以 HTTP `200 OK` 作为 OpenResty 主进程和 worker 正在提供服务的判断依据。
+15
View File
@@ -95,6 +95,17 @@ go run . --port 3000 --log-dir ./logs
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
| `UptimeKumaEnabled` | 是否启用 Uptime Kuma 自动同步 | `false` |
| `UptimeKumaUrl` | Uptime Kuma 实例地址 | 空 |
| `UptimeKumaUsername` | Uptime Kuma 登录用户名 | 空 |
| `UptimeKumaPassword` | Uptime Kuma 登录密码(写专,接口不回显) | 空 |
| `UptimeKumaMonitorScope` | 监控范围,支持 `all` (全部站点) 或 `selected` (选择站点) | `all` |
| `UptimeKumaSelectedSites` | 已选择监控站点的名称列表(英文逗号分隔) | 空 |
| `UptimeKumaSyncInterval` | 自动差分同步间隔(分钟) | `5` |
| `UptimeKumaInterval` | 监控心跳检测频率(秒) | `60` |
| `UptimeKumaRetry` | 监控最大重试次数 | `0` |
| `UptimeKumaRetryInterval` | 监控重试间隔时间(秒) | `60` |
| `UptimeKumaTimeout` | 监控请求超时断开时间(秒) | `48` |
说明:
@@ -157,6 +168,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
| `OPENFLARE_NODE_IP` | 节点 IP,可覆盖 `agent.json` | 空 |
| `OPENFLARE_DATA_DIR` | Agent 数据目录,可覆盖 `agent.json` | 空 |
| `OPENFLARE_OPENRESTY_PATH` | OpenResty 二进制路径,可覆盖 `agent.json` | 空 |
| `OPENFLARE_PAGES_DIR` | Pages 静态部署目录,可覆盖 `agent.json` | 空 |
| `OPENFLARE_HEARTBEAT_INTERVAL` | 心跳间隔,可覆盖 `agent.json` | 空 |
| `OPENFLARE_REQUEST_TIMEOUT` | 请求超时,可覆盖 `agent.json` | 空 |
| `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT` | 本地观测端口,可覆盖 `agent.json` | 空 |
@@ -190,6 +202,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
| `lua_dir` | Lua 脚本与静态资源写入目录 | 否 | `data_dir/etc/nginx/lua` |
| `openresty_lua_dir` | OpenResty 配置中读取 Lua 的目录 | 否 | 同 `lua_dir` |
| `runtime_config_dir` | Agent 运行时配置写入目录,如 `pow_config.json` | 否 | `data_dir/etc/openflare` |
| `pages_dir` | Pages 静态部署包解压与当前部署目录 | 否 | `data_dir/var/lib/openflare/pages` |
| `mmdb_path` | WAF GeoIP mmdb 文件路径 | 否 | `data_dir/etc/openflare/GeoLite2-Country.mmdb` |
| `mmdb_update_interval` | WAF GeoIP mmdb 更新间隔 | 否 | `86400000` 毫秒 |
| `mmdb_download_url` | WAF GeoIP mmdb 下载地址 | 否 | 内置 GeoLite2 Country 下载地址 |
@@ -207,6 +220,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
* 未配置 `openresty_path` 时默认调用 `openresty`。
* Agent 周期性健康检查会请求 `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status`,不再通过高频 `openresty -t` 判断运行时健康;配置应用、启动恢复和 reload 前校验仍会执行 `openresty -t -c <main_config_path>`。
* Agent 会初始化并定期更新 `mmdb_path`,供 OpenResty WAF Lua 执行国家级地域规则;更新失败只记录警告,不阻断同步或 reload。
* 当激活配置引用 Pages 部署时,Agent 会在应用 OpenResty 配置前,将部署包下载、校验并解压到 `pages_dir`,OpenResty 通过该目录服务静态文件。
* 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。
* Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。
@@ -323,6 +337,7 @@ go run .
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
"pages_dir": "/var/lib/openflare-agent/var/lib/openflare/pages",
"heartbeat_interval": 10000,
"request_timeout": 10000
}
+12 -2
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
@@ -10,12 +11,20 @@ ENV CGO_ENABLED=0 \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
WORKDIR /build
COPY openflare_server/go.mod openflare_server/go.sum ./openflare_server/
COPY openflare_agent/go.mod openflare_agent/go.sum ./openflare_agent/
WORKDIR /build/openflare_agent
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
WORKDIR /build
COPY openflare_server ./openflare_server
COPY openflare_agent ./openflare_agent
WORKDIR /build/openflare_agent
RUN go mod download
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
FROM openresty/openresty:alpine
@@ -32,3 +41,4 @@ COPY --from=builder /build/openflare-agent /usr/local/bin/openflare-agent
EXPOSE 80 443 18081
ENTRYPOINT ["/usr/local/bin/openflare-agent"]
CMD ["-config", "/etc/openflare/agent.json"]
+4 -1
View File
@@ -71,6 +71,7 @@ func main() {
LuaDir: cfg.LuaDir,
NginxLuaDir: cfg.OpenrestyLuaDir,
RuntimeConfigDir: cfg.RuntimeConfigDir,
PagesDir: cfg.PagesDir,
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort),
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
OpenrestyResolverDirective: "",
@@ -89,12 +90,14 @@ func main() {
slog.Error("ensure managed lua assets failed", "error", err)
os.Exit(1)
}
syncService := syncservice.New(client, runtimeManager, stateStore)
syncService.SetPagesDir(cfg.PagesDir)
runner := &agent.Runner{
Config: cfg,
StateStore: stateStore,
ObservabilityBuffer: observabilityBuffer,
HeartbeatService: heartbeat.New(client),
SyncService: syncservice.New(client, runtimeManager, stateStore),
SyncService: syncService,
Updater: updater.New(),
RuntimeManager: runtimeManager,
WebSocketService: wsClient,
+3 -5
View File
@@ -1,17 +1,15 @@
module openflare-agent
go 1.25.0
go 1.25.7
require (
golang.org/x/net v0.53.0
openflare v0.0.0
)
require openflare v0.0.0
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
)
+10
View File
@@ -23,6 +23,7 @@ const (
defaultCertDirRelativePath = "etc/nginx/certs"
defaultLuaDirRelativePath = "etc/nginx/lua"
defaultRuntimeConfigDirRelativePath = "etc/openflare"
defaultPagesDirRelativePath = "var/lib/openflare/pages"
defaultMMDBRelativePath = "etc/openflare/GeoLite2-Country.mmdb"
defaultAccessLogRelativePath = "var/log/openflare/access.log"
defaultStateRelativePath = "var/lib/openflare/agent-state.json"
@@ -57,6 +58,7 @@ type Config struct {
LuaDir string `json:"lua_dir"`
OpenrestyLuaDir string `json:"openresty_lua_dir"`
RuntimeConfigDir string `json:"runtime_config_dir"`
PagesDir string `json:"pages_dir"`
MMDBPath string `json:"mmdb_path"`
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
MMDBDownloadURL string `json:"mmdb_download_url"`
@@ -86,6 +88,7 @@ type configFile struct {
LuaDir string `json:"lua_dir"`
OpenrestyLuaDir string `json:"openresty_lua_dir"`
RuntimeConfigDir string `json:"runtime_config_dir"`
PagesDir string `json:"pages_dir"`
MMDBPath string `json:"mmdb_path"`
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
MMDBDownloadURL string `json:"mmdb_download_url"`
@@ -128,6 +131,7 @@ func Load(path string) (*Config, error) {
LuaDir: file.LuaDir,
OpenrestyLuaDir: file.OpenrestyLuaDir,
RuntimeConfigDir: file.RuntimeConfigDir,
PagesDir: file.PagesDir,
MMDBPath: file.MMDBPath,
MMDBUpdateInterval: file.MMDBUpdateInterval,
MMDBDownloadURL: file.MMDBDownloadURL,
@@ -190,6 +194,9 @@ func applyDefaults(cfg *Config, baseDir string) {
if cfg.RuntimeConfigDir == "" {
cfg.RuntimeConfigDir = joinManagedPath(cfg.DataDir, defaultRuntimeConfigDirRelativePath)
}
if cfg.PagesDir == "" {
cfg.PagesDir = joinManagedPath(cfg.DataDir, defaultPagesDirRelativePath)
}
if cfg.MMDBPath == "" {
cfg.MMDBPath = joinManagedPath(cfg.DataDir, defaultMMDBRelativePath)
}
@@ -231,6 +238,7 @@ func normalizeManagedPaths(cfg *Config) {
&cfg.LuaDir,
&cfg.OpenrestyLuaDir,
&cfg.RuntimeConfigDir,
&cfg.PagesDir,
&cfg.StatePath,
&cfg.ObservabilityBufferPath,
&cfg.MMDBPath,
@@ -251,6 +259,7 @@ func hasEnvConfig() bool {
"OPENFLARE_NODE_IP",
"OPENFLARE_DATA_DIR",
"OPENFLARE_OPENRESTY_PATH",
"OPENFLARE_PAGES_DIR",
"OPENFLARE_HEARTBEAT_INTERVAL",
"OPENFLARE_REQUEST_TIMEOUT",
"OPENFLARE_OPENRESTY_OBSERVABILITY_PORT",
@@ -281,6 +290,7 @@ func applyEnvOverrides(cfg *Config) {
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir)
overrideString("OPENFLARE_OPENRESTY_PATH", &cfg.OpenrestyPath)
overrideString("OPENFLARE_PAGES_DIR", &cfg.PagesDir)
overrideString("OPENFLARE_MMDB_PATH", &cfg.MMDBPath)
overrideString("OPENFLARE_MMDB_DOWNLOAD_URL", &cfg.MMDBDownloadURL)
if value := strings.TrimSpace(os.Getenv("OPENFLARE_HEARTBEAT_INTERVAL")); value != "" {
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
@@ -86,6 +87,23 @@ func (c *Client) SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGrou
return &resp.Data, nil
}
func (c *Client) DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+fmt.Sprintf("/api/agent/pages/deployments/%d/package", deploymentID), nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Agent-Token", c.token)
res, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, errors.New(res.Status)
}
return io.ReadAll(res.Body)
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
slog.Debug("http client token updated")
+11
View File
@@ -141,6 +141,7 @@ type Manager struct {
LuaDir string
NginxLuaDir string
RuntimeConfigDir string
PagesDir string
OpenrestyObservabilityListen string
OpenrestyObservabilityPort int
OpenrestyResolverDirective string
@@ -422,6 +423,9 @@ func (m *Manager) CurrentChecksum() (string, error) {
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", openrestyrender.PowStaticDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, openrestyrender.LuaDirPlaceholder)
}
if pagesDir := m.pagesRuntimePath(); pagesDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, pagesDir, openrestyrender.PagesDirPlaceholder)
}
files, err := m.readManagedSupportFiles()
if err != nil {
return "", err
@@ -1130,6 +1134,9 @@ func (m *Manager) renderRouteConfig(content string) string {
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
rendered = strings.ReplaceAll(rendered, openrestyrender.PowStaticDirPlaceholder, luaDir+"/pow/static")
}
if pagesDir := m.pagesRuntimePath(); pagesDir != "" {
rendered = strings.ReplaceAll(rendered, openrestyrender.PagesDirPlaceholder, pagesDir)
}
return rendered
}
@@ -1258,6 +1265,10 @@ func (m *Manager) luaRuntimePath() string {
return filepath.ToSlash(m.NginxLuaDir)
}
func (m *Manager) pagesRuntimePath() string {
return filepath.ToSlash(strings.TrimSpace(m.PagesDir))
}
func checksum(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
@@ -549,7 +549,7 @@ func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
if err != nil {
t.Fatalf("failed to read pow lua file: %v", err)
}
if !strings.Contains(string(data), filepath.ToSlash(manager.RuntimeConfigDir)+"/pow_config.json") {
if !strings.Contains(string(data), filepath.ToSlash(manager.RuntimeConfigDir)+"/waf_config.json") {
t.Fatalf("expected pow lua to read runtime config dir, got %s", string(data))
}
}
+48 -15
View File
@@ -39,9 +39,9 @@ end
-- Lazy-load pow_config from file; reload when content changes
local function load_pow_config()
local config_paths = {
"__OPENFLARE_RUNTIME_CONFIG_DIR__/pow_config.json",
"/etc/nginx/openflare-lua/pow_config.json",
"/usr/local/openresty/nginx/conf/pow_config.json"
"__OPENFLARE_RUNTIME_CONFIG_DIR__/waf_config.json",
"/etc/nginx/openflare-lua/waf_config.json",
"/usr/local/openresty/nginx/conf/waf_config.json"
}
for _, config_path in ipairs(config_paths) do
local f = io.open(config_path, "r")
@@ -54,7 +54,7 @@ local function load_pow_config()
return
end
-- Clear old domain entries
-- Clear old domain/site entries
local old_keys = pow_config_dict:get("_domain_keys")
if old_keys then
for domain in string.gmatch(old_keys, "[^\n]+") do
@@ -64,15 +64,38 @@ local function load_pow_config()
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
local ok, decoded = pcall(cjson.decode, content)
if ok and decoded and decoded.rule_groups and decoded.site_rule_groups then
-- Build rule groups map (group ID -> PoWConfig)
local groups = {}
for _, group in ipairs(decoded.rule_groups) do
if group.pow_enabled then
groups[tostring(group.id)] = group.pow_config
end
end
-- Build site name to pow_config map
for site, group_ids in pairs(decoded.site_rule_groups) do
local pow_config = nil
-- Check custom group IDs first
for _, id in ipairs(group_ids) do
pow_config = groups[tostring(id)]
if pow_config then
break
end
end
-- If not found, check global group IDs
if not pow_config then
for _, group in ipairs(decoded.rule_groups) do
if group.is_global and group.pow_enabled then
pow_config = group.pow_config
break
end
end
end
if pow_config then
pow_config_dict:set(site, cjson.encode({enabled = true, config = pow_config}), 0)
domain_keys[#domain_keys+1] = site
end
end
end
end
@@ -91,7 +114,12 @@ if not host or host == "" then
return
end
local config_raw = pow_config_dict:get(host)
local site = ngx.var.openflare_waf_site or ""
if site == "" then
site = host
end
local config_raw = pow_config_dict:get(site)
if not config_raw then
return
end
@@ -199,17 +227,22 @@ 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)
local site = ngx.var.openflare_waf_site or ""
if site == "" then
site = host
end
local config_raw = pow_config_dict:get(site)
if not config_raw then
ngx.status = 403
ngx.say("PoW not configured for this host")
ngx.say("PoW not configured for this site")
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")
ngx.say("PoW not enabled for this site")
return
end
+328
View File
@@ -0,0 +1,328 @@
package sync
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"openflare-agent/internal/protocol"
)
type pagesSourceDocument struct {
Routes []pagesSourceRoute `json:"routes"`
}
type pagesSourceRoute struct {
UpstreamType string `json:"upstream_type"`
PagesDeployment *pagesDeploymentSource `json:"pages_deployment"`
}
type pagesDeploymentSource struct {
DeploymentID uint `json:"deployment_id"`
Checksum string `json:"checksum"`
}
type pagesDeploymentMarker struct {
DeploymentID uint `json:"deployment_id"`
Checksum string `json:"checksum"`
}
func (s *Service) syncPagesDeployments(ctx context.Context, config *protocol.ActiveConfigResponse) error {
deployments, err := referencedPagesDeployments(config)
if err != nil {
return err
}
if len(deployments) == 0 {
return nil
}
if strings.TrimSpace(s.pagesDir) == "" {
return errors.New("pages_dir is required when active config references Pages deployments")
}
for _, deployment := range deployments {
if err := s.ensurePagesDeployment(ctx, deployment); err != nil {
return err
}
}
return nil
}
func (s *Service) ensurePagesDeployment(ctx context.Context, deployment pagesDeploymentSource) error {
currentDir := pagesCurrentDir(s.pagesDir, deployment.DeploymentID)
if markerMatches(currentDir, deployment) {
return nil
}
packageBytes, err := s.client.DownloadPagesDeploymentPackage(ctx, deployment.DeploymentID)
if err != nil {
return fmt.Errorf("download Pages deployment %d: %w", deployment.DeploymentID, err)
}
if got := checksumBytes(packageBytes); got != deployment.Checksum {
return fmt.Errorf("Pages deployment %d checksum mismatch: expected %s, got %s", deployment.DeploymentID, deployment.Checksum, got)
}
releaseDir := pagesReleaseDir(s.pagesDir, deployment.DeploymentID, deployment.Checksum)
if !markerMatches(releaseDir, deployment) {
if err := extractPagesPackage(packageBytes, releaseDir, deployment); err != nil {
return err
}
}
return switchPagesCurrentDir(s.pagesDir, deployment.DeploymentID, releaseDir)
}
func referencedPagesDeployments(config *protocol.ActiveConfigResponse) ([]pagesDeploymentSource, error) {
if config == nil || strings.TrimSpace(config.SourceConfigJSON) == "" {
return nil, nil
}
var doc pagesSourceDocument
if err := json.Unmarshal([]byte(config.SourceConfigJSON), &doc); err != nil {
return nil, fmt.Errorf("decode Pages references: %w", err)
}
seen := make(map[uint]struct{})
result := make([]pagesDeploymentSource, 0)
for _, route := range doc.Routes {
if strings.ToLower(strings.TrimSpace(route.UpstreamType)) != "pages" || route.PagesDeployment == nil {
continue
}
deploymentID := route.PagesDeployment.DeploymentID
checksum := strings.TrimSpace(route.PagesDeployment.Checksum)
if deploymentID == 0 || checksum == "" {
return nil, errors.New("Pages deployment snapshot is incomplete")
}
if _, ok := seen[deploymentID]; ok {
continue
}
seen[deploymentID] = struct{}{}
result = append(result, pagesDeploymentSource{DeploymentID: deploymentID, Checksum: checksum})
}
return result, nil
}
func findCommonRootPrefix(files []*zip.File) (string, error) {
var firstFilePath string
hasMultipleFiles := false
for _, item := range files {
relativePath, skip, err := normalizePagesArchivePath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
normalizedPath := filepath.ToSlash(relativePath)
if firstFilePath == "" {
firstFilePath = normalizedPath
} else {
hasMultipleFiles = true
}
}
if firstFilePath == "" {
return "", nil
}
parts := strings.Split(firstFilePath, "/")
if len(parts) <= 1 {
return "", nil
}
commonPrefix := parts[0] + "/"
if hasMultipleFiles {
for _, item := range files {
relativePath, skip, err := normalizePagesArchivePath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
normalizedPath := filepath.ToSlash(relativePath)
if !strings.HasPrefix(normalizedPath, commonPrefix) {
return "", nil
}
}
}
return commonPrefix, nil
}
func extractPagesPackage(packageBytes []byte, releaseDir string, deployment pagesDeploymentSource) error {
tmpDir := releaseDir + ".tmp"
_ = os.RemoveAll(tmpDir)
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return err
}
reader, err := zip.NewReader(bytes.NewReader(packageBytes), int64(len(packageBytes)))
if err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("open Pages zip: %w", err)
}
commonPrefix, err := findCommonRootPrefix(reader.File)
if err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
for _, item := range reader.File {
relativePath, skip, err := normalizePagesArchivePath(item.Name)
if err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
if skip {
continue
}
if commonPrefix != "" {
slashPath := filepath.ToSlash(relativePath)
if strings.HasPrefix(slashPath, commonPrefix) {
relativePath = filepath.FromSlash(strings.TrimPrefix(slashPath, commonPrefix))
}
}
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("Pages package contains unsupported symlink: %s", relativePath)
}
if err := extractPagesFile(item, filepath.Join(tmpDir, relativePath)); err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
}
if err := writePagesMarker(tmpDir, deployment); err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
_ = os.RemoveAll(releaseDir)
return os.Rename(tmpDir, releaseDir)
}
func extractPagesFile(item *zip.File, targetPath string) error {
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
source, err := item.Open()
if err != nil {
return err
}
defer source.Close()
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, item.FileInfo().Mode().Perm())
if err != nil {
return err
}
defer target.Close()
_, err = io.Copy(target, source)
return err
}
func switchPagesCurrentDir(baseDir string, deploymentID uint, releaseDir string) error {
currentDir := pagesCurrentDir(baseDir, deploymentID)
previousDir := currentDir + ".previous"
_ = os.RemoveAll(previousDir)
if err := os.MkdirAll(filepath.Dir(currentDir), 0o755); err != nil {
return err
}
if _, err := os.Stat(currentDir); err == nil {
if err := os.Rename(currentDir, previousDir); err != nil {
return err
}
}
if err := copyPagesDir(releaseDir, currentDir); err != nil {
_ = os.RemoveAll(currentDir)
if _, restoreErr := os.Stat(previousDir); restoreErr == nil {
_ = os.Rename(previousDir, currentDir)
}
return err
}
_ = os.RemoveAll(previousDir)
return nil
}
func copyPagesDir(sourceDir string, targetDir string) error {
return filepath.WalkDir(sourceDir, func(sourcePath string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
relativePath, err := filepath.Rel(sourceDir, sourcePath)
if err != nil || relativePath == "." {
return err
}
targetPath := filepath.Join(targetDir, relativePath)
if entry.IsDir() {
return os.MkdirAll(targetPath, 0o755)
}
info, err := entry.Info()
if err != nil {
return err
}
input, err := os.Open(sourcePath)
if err != nil {
return err
}
defer input.Close()
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
output, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
defer output.Close()
_, err = io.Copy(output, input)
return err
})
}
func normalizePagesArchivePath(raw string) (string, bool, error) {
name := strings.TrimSpace(filepath.ToSlash(raw))
if name == "" || strings.HasSuffix(name, "/") {
return "", true, nil
}
if strings.HasPrefix(name, "/") {
return "", false, fmt.Errorf("Pages package contains absolute path: %s", raw)
}
cleaned := path.Clean(name)
if cleaned == "." {
return "", true, nil
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
return "", false, fmt.Errorf("Pages package path escapes deployment root: %s", raw)
}
return filepath.FromSlash(cleaned), false, nil
}
func markerMatches(dir string, deployment pagesDeploymentSource) bool {
data, err := os.ReadFile(filepath.Join(dir, ".openflare-pages.json"))
if err != nil {
return false
}
var marker pagesDeploymentMarker
if err := json.Unmarshal(data, &marker); err != nil {
return false
}
return marker.DeploymentID == deployment.DeploymentID && marker.Checksum == deployment.Checksum
}
func writePagesMarker(dir string, deployment pagesDeploymentSource) error {
data, err := json.Marshal(pagesDeploymentMarker{
DeploymentID: deployment.DeploymentID,
Checksum: deployment.Checksum,
})
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, ".openflare-pages.json"), data, 0o644)
}
func pagesCurrentDir(baseDir string, deploymentID uint) string {
return filepath.Join(baseDir, "deployments", fmt.Sprintf("%d", deploymentID), "current")
}
func pagesReleaseDir(baseDir string, deploymentID uint, checksum string) string {
return filepath.Join(baseDir, "deployments", fmt.Sprintf("%d", deploymentID), "releases", checksum)
}
func checksumBytes(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
+9
View File
@@ -25,6 +25,7 @@ const (
type ConfigClient interface {
GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error)
DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error)
ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error
SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error)
}
@@ -42,6 +43,11 @@ type Service struct {
client ConfigClient
nginxManager NginxManager
stateStore *state.Store
pagesDir string
}
func (s *Service) SetPagesDir(path string) {
s.pagesDir = strings.TrimSpace(path)
}
func New(client ConfigClient, nginxManager NginxManager, stateStore *state.Store) *Service {
@@ -225,6 +231,9 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
if err != nil {
return err
}
if err := s.syncPagesDeployments(ctx, config); err != nil {
return err
}
mainConfigChecksum := checksumString(rendered.mainConfig)
routeConfigChecksum := checksumString(rendered.routeConfig)
slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum)
+165 -5
View File
@@ -1,7 +1,11 @@
package sync
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
@@ -19,10 +23,15 @@ type fakeExecutor struct {
reloadErr error
}
func testPagesSourceConfigJSON(deploymentID uint, checksum string) string {
return fmt.Sprintf(`{"routes":[{"id":1,"site_name":"pages","domain":"pages.example.com","domains":["pages.example.com"],"origin_url":"openflare-pages://project/1","upstreams":["openflare-pages://project/1"],"enabled":true,"upstream_type":"pages","pages_deployment":{"project_id":1,"project_slug":"pages","deployment_id":%d,"deployment_number":1,"checksum":"%s","entry_file":"index.html","spa_fallback_enabled":true,"local_root":"__OPENFLARE_PAGES_DIR__/deployments/%d/current"}}],"openresty_config":{"worker_processes":"auto","worker_connections":1024,"worker_rlimit_nofile":65535,"events_multi_accept_enabled":true,"keepalive_timeout":20,"keepalive_requests":1000,"client_header_timeout":15,"client_body_timeout":15,"client_max_body_size":"64m","large_client_header_buffers":"4 16k","send_timeout":30,"proxy_connect_timeout":3,"proxy_send_timeout":60,"proxy_read_timeout":60,"websocket_enabled":true,"proxy_request_buffering":false,"proxy_buffering_enabled":true,"proxy_buffers":"16 16k","proxy_buffer_size":"8k","proxy_busy_buffers_size":"64k","gzip_enabled":true,"gzip_min_length":1024,"gzip_comp_level":5,"cache_enabled":false,"cache_levels":"1:2","cache_inactive":"30m","cache_max_size":"1g","cache_key_template":"$scheme$host$request_uri","cache_lock_enabled":true,"cache_lock_timeout":"5s","cache_use_stale":"error timeout updating http_500 http_502 http_503 http_504","main_config_template":"worker_processes {{OpenRestyWorkerProcesses}};"},"waf":{"rule_groups":[],"bindings":[]}}`, deploymentID, checksum, deploymentID)
}
type fakeClient struct {
config protocol.ActiveConfigResponse
reports []protocol.ApplyLogPayload
fetchCalls int
config protocol.ActiveConfigResponse
reports []protocol.ApplyLogPayload
pagesPackages map[uint][]byte
fetchCalls int
}
type fakeManager struct {
@@ -67,6 +76,13 @@ func (f *fakeClient) GetActiveConfig(ctx context.Context) (*protocol.ActiveConfi
return &f.config, nil
}
func (f *fakeClient) DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error) {
if f.pagesPackages == nil {
return nil, fmt.Errorf("missing Pages package %d", deploymentID)
}
return f.pagesPackages[deploymentID], nil
}
func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error {
f.reports = append(f.reports, payload)
return nil
@@ -174,11 +190,82 @@ func TestSyncOnceSuccess(t *testing.T) {
if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" {
t.Fatal("expected main and route config checksums to be reported")
}
if client.reports[0].SupportFileCount != 4 {
if client.reports[0].SupportFileCount != 3 {
t.Fatalf("expected support file count to be reported, got %d", client.reports[0].SupportFileCount)
}
}
func TestSyncOnceDownloadsPagesDeploymentBeforeApply(t *testing.T) {
packageBytes := testPagesPackage(t, map[string]string{"index.html": "hello"})
checksum := testBytesChecksum(packageBytes)
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-101",
Checksum: "pages-config-checksum",
SourceConfigJSON: testPagesSourceConfigJSON(7, checksum),
CreatedAt: time.Now().Format(time.RFC3339),
},
pagesPackages: map[uint][]byte{7: packageBytes},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
snapshot, _ := stateStore.Load()
snapshot.NodeID = nodeID
if err = stateStore.Save(snapshot); err != nil {
t.Fatalf("save state failed: %v", err)
}
manager := &fakeManager{currentChecksum: "old-checksum"}
service := New(client, manager, stateStore)
pagesDir := t.TempDir()
service.SetPagesDir(pagesDir)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-101", Checksum: "pages-config-checksum"}); err != nil {
t.Fatalf("SyncOnce failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(pagesDir, "deployments", "7", "current", "index.html"))
if err != nil {
t.Fatalf("expected Pages file to be extracted: %v", err)
}
if string(data) != "hello" {
t.Fatalf("unexpected Pages file content: %s", string(data))
}
if len(manager.applyRouteContents) != 1 || !strings.Contains(manager.applyRouteContents[0], "__OPENFLARE_PAGES_DIR__/deployments/7/current") {
t.Fatalf("expected Pages placeholder in rendered route config, got %#v", manager.applyRouteContents)
}
}
func TestSyncOnceRejectsPagesZipSlipBeforeApply(t *testing.T) {
packageBytes := testPagesPackage(t, map[string]string{"../escape.html": "bad", "index.html": "ok"})
checksum := testBytesChecksum(packageBytes)
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-102",
Checksum: "pages-config-checksum",
SourceConfigJSON: testPagesSourceConfigJSON(8, checksum),
CreatedAt: time.Now().Format(time.RFC3339),
},
pagesPackages: map[uint][]byte{8: packageBytes},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
if _, err := stateStore.EnsureNodeID(); err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
manager := &fakeManager{currentChecksum: "old-checksum"}
service := New(client, manager, stateStore)
service.SetPagesDir(t.TempDir())
err := service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-102", Checksum: "pages-config-checksum"})
if err == nil || !strings.Contains(err.Error(), "escapes deployment root") {
t.Fatalf("expected zip-slip rejection, got %v", err)
}
if len(manager.applyRouteContents) != 0 {
t.Fatalf("OpenResty apply must not run after Pages package rejection")
}
}
func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
@@ -239,7 +326,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" {
t.Fatal("expected failed report to include main and route config checksums")
}
if client.reports[0].SupportFileCount != 4 {
if client.reports[0].SupportFileCount != 3 {
t.Fatalf("expected failed report to include support file count, got %d", client.reports[0].SupportFileCount)
}
}
@@ -761,3 +848,76 @@ func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) {
t.Fatal("expected no apply log when no config change is needed")
}
}
func testPagesPackage(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, content := range files {
file, err := writer.Create(name)
if err != nil {
t.Fatalf("create zip file failed: %v", err)
}
if _, err := file.Write([]byte(content)); err != nil {
t.Fatalf("write zip file failed: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close zip failed: %v", err)
}
return buffer.Bytes()
}
func testBytesChecksum(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func TestSyncOnceDownloadsPagesDeploymentWithTopLevelFolder(t *testing.T) {
packageBytes := testPagesPackage(t, map[string]string{
"Speed-Test-source/index.html": "hello html",
"Speed-Test-source/assets/app.js": "hello js",
})
checksum := testBytesChecksum(packageBytes)
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-105",
Checksum: "pages-config-checksum",
SourceConfigJSON: testPagesSourceConfigJSON(77, checksum),
CreatedAt: time.Now().Format(time.RFC3339),
},
pagesPackages: map[uint][]byte{77: packageBytes},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
snapshot, _ := stateStore.Load()
snapshot.NodeID = nodeID
if err = stateStore.Save(snapshot); err != nil {
t.Fatalf("save state failed: %v", err)
}
manager := &fakeManager{currentChecksum: "old-checksum"}
service := New(client, manager, stateStore)
pagesDir := t.TempDir()
service.SetPagesDir(pagesDir)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-105", Checksum: "pages-config-checksum"}); err != nil {
t.Fatalf("SyncOnce failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(pagesDir, "deployments", "77", "current", "index.html"))
if err != nil {
t.Fatalf("expected Pages index.html file to be extracted: %v", err)
}
if string(data) != "hello html" {
t.Fatalf("unexpected Pages index.html content: %s", string(data))
}
jsData, err := os.ReadFile(filepath.Join(pagesDir, "deployments", "77", "current", "assets", "app.js"))
if err != nil {
t.Fatalf("expected Pages assets/app.js file to be extracted: %v", err)
}
if string(jsData) != "hello js" {
t.Fatalf("unexpected Pages assets/app.js content: %s", string(jsData))
}
}
+11 -3
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
@@ -5,13 +6,19 @@ FROM golang:1.25-alpine AS builder
ARG VERSION
WORKDIR /build
COPY openflare_server/go.mod openflare_server/go.sum /openflare_server/
COPY openflare_relay/go.mod openflare_relay/go.sum /openflare_relay/
WORKDIR /openflare_relay
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY openflare_relay/go.mod openflare_relay/go.sum ./
WORKDIR /build
COPY openflare_server /openflare_server
COPY openflare_relay /openflare_relay
WORKDIR /openflare_relay
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o openflare-relay ./cmd/relay
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o openflare-relay ./cmd/relay
# Final runtime image
FROM fatedier/frps:v0.69.0
@@ -25,3 +32,4 @@ ENV OPENFLARE_FRPS_PATH=/usr/bin/frps
ENV OPENFLARE_DATA_DIR=/var/lib/openflare-relay
ENTRYPOINT ["/usr/local/bin/openflare-relay"]
+13 -10
View File
@@ -1,13 +1,10 @@
module openflare-relay
go 1.25.0
go 1.25.7
replace openflare => ../openflare_server
require (
golang.org/x/net v0.55.0
openflare v0.0.0-00010101000000-000000000000
)
require openflare v0.0.0-00010101000000-000000000000
require (
github.com/bwmarrin/snowflake v0.3.0 // indirect
@@ -35,7 +32,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -44,18 +41,24 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pressly/goose/v3 v3.27.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
@@ -65,8 +68,8 @@ require (
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.25.10 // 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
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+48 -15
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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=
@@ -55,8 +57,8 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
github.com/go-playground/validator/v10 v10.23.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-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@@ -64,16 +66,18 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -98,6 +102,8 @@ github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUK
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -106,6 +112,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
@@ -119,11 +127,14 @@ github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
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=
@@ -139,6 +150,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
@@ -183,13 +196,33 @@ gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
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=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+30
View File
@@ -19,6 +19,7 @@ type Manager struct {
frpsPath string
dataDir string
configPath string
pidPath string
agentToken string
mu sync.RWMutex
@@ -45,6 +46,7 @@ func NewManager(frpsPath string, dataDir string, agentToken string) *Manager {
frpsPath: frpsPath,
dataDir: dataDir,
configPath: filepath.Join(dataDir, "frps.toml"),
pidPath: filepath.Join(dataDir, "frps.pid"),
status: "unknown", // 启动阶段尚未获取配置,状态未知;避免首次 heartbeat 误报 frps_unhealthy
agentToken: agentToken,
}
@@ -183,6 +185,8 @@ func (m *Manager) supervise(generation uint64) {
return
}
ensureNoOrphanProcess(m.pidPath)
cmd := exec.Command(m.frpsPath, "-c", m.configPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -204,6 +208,8 @@ func (m *Manager) supervise(generation uint64) {
continue
}
_ = os.WriteFile(m.pidPath, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644)
m.cmd = cmd
m.status = "healthy"
m.lastError = ""
@@ -211,6 +217,7 @@ func (m *Manager) supervise(generation uint64) {
startedAt := time.Now()
waitErr := cmd.Wait()
_ = os.Remove(m.pidPath)
m.mu.Lock()
if m.cmd == cmd {
@@ -272,5 +279,28 @@ func (m *Manager) Stop() {
_ = m.cmd.Process.Kill()
m.cmd = nil
}
_ = os.Remove(m.pidPath)
m.status = "unhealthy"
}
func ensureNoOrphanProcess(pidPath string) {
data, err := os.ReadFile(pidPath)
if err != nil {
return
}
var pid int
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
return
}
if pid <= 0 {
return
}
process, err := os.FindProcess(pid)
if err == nil && process != nil {
slog.Warn("attempting to kill potentially orphan process", "pid", pid, "pid_path", pidPath)
_ = process.Kill()
// Wait a little bit to ensure the OS has reclaimed ports
time.Sleep(500 * time.Millisecond)
}
_ = os.Remove(pidPath)
}
@@ -3,6 +3,7 @@ package frps
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
@@ -63,6 +64,21 @@ func assertStatusEventually(t *testing.T, m *Manager, expectedStatus string, tim
t.Fatalf("expected status eventually %s, got %s (err: %s)", expectedStatus, rt.Status, rt.LastError)
}
func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
t.Helper()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-time.After(timeout):
t.Fatalf("expected process pid=%d to exit within %s", cmd.Process.Pid, timeout)
case <-done:
}
}
func TestStartProcessSuccess(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 5) // exit code 0, sleep 5s
@@ -293,3 +309,37 @@ func TestSupervisorGenerationInterrupt(t *testing.T) {
t.Error("expected first process to be killed")
}
}
func TestUpdateConfigKillsOrphanProcessBeforeRestart(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 5)
m := NewManager(scriptPath, dir, "agent-token")
defer m.Stop()
orphan := exec.Command("sh", "-c", "sleep 30")
if err := orphan.Start(); err != nil {
t.Fatalf("failed to start orphan process: %v", err)
}
t.Cleanup(func() {
if orphan.Process != nil {
_ = orphan.Process.Kill()
}
})
if err := os.WriteFile(m.pidPath, []byte(fmt.Sprintf("%d", orphan.Process.Pid)), 0o644); err != nil {
t.Fatalf("failed to seed orphan pid file: %v", err)
}
cfg := &service.RelayConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
AuthToken: "test-auth",
WebServerEnabled: false,
}
m.UpdateConfig(cfg)
assertCommandExitedEventually(t, orphan, 2*time.Second)
assertStatusEventually(t, m, "healthy", 2*time.Second)
}
+11 -5
View File
@@ -1,10 +1,16 @@
.git
.github
node_modules
web/node_modules
web/.next
web/build
web/out
upload
dist
tmp
logs
.git
.github
upload
*.log
.DS_Store
.env
.env.*
docker-compose*.yml
+21 -21
View File
@@ -1,38 +1,38 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM node:20 AS builder
FROM node:20 AS web-builder
ARG VERSION
WORKDIR /build
COPY ./web/package.json ./
COPY ./web/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
RUN corepack enable
COPY ./web/package.json ./web/pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY ./web ./
RUN NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
FROM golang:1.25 AS builder2
RUN --mount=type=cache,id=next-cache,target=/build/.next/cache \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
FROM golang:1.25 AS go-builder
ARG VERSION
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
COPY --from=builder /build/build ./web/build
RUN go mod download
RUN go build -trimpath -ldflags "-s -w -X 'openflare/common.Version=$VERSION'" -o openflare
COPY --from=web-builder /build/build ./web/build
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath \
-ldflags "-s -w -X 'openflare/common.Version=$VERSION'" \
-o openflare
FROM alpine
RUN apk update \
&& apk upgrade \
&& apk add --no-cache ca-certificates tzdata \
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates 2>/dev/null || true
ENV PORT=3000
COPY --from=builder2 /build/openflare /
COPY --from=go-builder /build/openflare /openflare
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/openflare"]
+13
View File
@@ -57,6 +57,19 @@ var GeoIPProvider = "ipinfo"
var DatabaseAutoCleanupEnabled = false
var DatabaseAutoCleanupRetentionDays = 30
// Uptime Kuma integration settings
var UptimeKumaEnabled = false
var UptimeKumaUrl = ""
var UptimeKumaUsername = ""
var UptimeKumaPassword = ""
var UptimeKumaMonitorScope = "all" // "all" or "selected"
var UptimeKumaSelectedSites = "" // Comma-separated list of site names
var UptimeKumaSyncInterval = 5 // minutes
var UptimeKumaInterval = 60 // seconds
var UptimeKumaRetry = 0
var UptimeKumaRetryInterval = 60 // seconds
var UptimeKumaTimeout = 48 // seconds
// V5 OpenResty performance settings (hot-reloadable via Option table)
var OpenRestyDefaultServerReturnStatus = 421
+58 -32
View File
@@ -100,6 +100,56 @@ func validateAgentOption(key string, value string) error {
}
}
func validateUptimeKumaOption(key string, value string, state map[string]string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "UptimeKumaEnabled":
if err := validateBooleanOption(key, trimmed); err != nil {
return err
}
if trimmed == "true" {
url := strings.TrimSpace(state["UptimeKumaUrl"])
username := strings.TrimSpace(state["UptimeKumaUsername"])
password := strings.TrimSpace(state["UptimeKumaPassword"])
if url == "" {
return fmt.Errorf("启用 Uptime Kuma 时地址不能为空")
}
if username == "" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
if password == "" && common.UptimeKumaPassword == "" {
return fmt.Errorf("启用 Uptime Kuma 时密码不能为空")
}
}
case "UptimeKumaUsername":
if trimmed == "" && state["UptimeKumaEnabled"] == "true" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
case "UptimeKumaPassword":
// No specific format checks needed
case "UptimeKumaUrl":
if trimmed != "" {
if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
return fmt.Errorf("Uptime Kuma 地址必须以 http:// 或 https:// 开头")
}
}
case "UptimeKumaMonitorScope":
if trimmed != "all" && trimmed != "selected" {
return fmt.Errorf("监控范围必须为全部站点 (all) 或选择站点 (selected)")
}
case "UptimeKumaSyncInterval", "UptimeKumaInterval", "UptimeKumaRetryInterval", "UptimeKumaTimeout":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
case "UptimeKumaRetry":
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
}
}
return nil
}
func validateOpenRestyOption(key string, value string) error {
trimmed := strings.TrimSpace(value)
@@ -263,6 +313,9 @@ func validateOptionWithState(option model.Option, state map[string]string) error
if err := validateAgentOption(option.Key, option.Value); err != nil {
return err
}
if err := validateUptimeKumaOption(option.Key, option.Value, state); err != nil {
return err
}
return nil
}
@@ -292,9 +345,9 @@ func updateOptions(options []model.Option) error {
// @Router /api/option/ [get]
func GetOptions(c *gin.Context) {
var options []*model.Option
common.OptionMapRWMutex.Lock()
common.OptionMapRWMutex.RLock()
for k, v := range common.OptionMap {
if strings.Contains(k, "Token") || strings.Contains(k, "Secret") {
if strings.Contains(k, "Token") || strings.Contains(k, "Secret") || strings.Contains(k, "Password") {
continue
}
options = append(options, &model.Option{
@@ -302,7 +355,7 @@ func GetOptions(c *gin.Context) {
Value: utils.Interface2String(v),
})
}
common.OptionMapRWMutex.Unlock()
common.OptionMapRWMutex.RUnlock()
respondSuccess(c, options)
}
@@ -320,35 +373,8 @@ func UpdateOption(c *gin.Context) {
if !bindJSON(c, &option) {
return
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
respondFailure(c, "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!")
return
}
case "WeChatAuthEnabled":
if option.Value == "true" && common.WeChatServerAddress == "" {
respondFailure(c, "无法启用微信登录,请先填入微信登录相关配置信息!")
return
}
}
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateAgentOption(option.Key, option.Value); err != nil {
state := buildOptionValidationState([]model.Option{option})
if err := validateOptionWithState(option, state); err != nil {
respondFailure(c, err.Error())
return
}
+55 -1
View File
@@ -1,6 +1,8 @@
package controller
import "testing"
import (
"testing"
)
func TestValidateOpenRestyOption(t *testing.T) {
testCases := []struct {
@@ -63,3 +65,55 @@ func TestValidateAgentOption(t *testing.T) {
t.Fatal("expected websocket upgrade option to reject non-boolean value")
}
}
func TestValidateUptimeKumaOption(t *testing.T) {
state := map[string]string{
"UptimeKumaUrl": "http://localhost:3001",
"UptimeKumaUsername": "admin",
"UptimeKumaPassword": "password",
}
testCases := []struct {
name string
key string
value string
wantErr bool
}{
{name: "enabled true", key: "UptimeKumaEnabled", value: "true"},
{name: "enabled false", key: "UptimeKumaEnabled", value: "false"},
{name: "enabled invalid", key: "UptimeKumaEnabled", value: "on", wantErr: true},
{name: "url http valid", key: "UptimeKumaUrl", value: "http://192.168.1.100:3001"},
{name: "url https valid", key: "UptimeKumaUrl", value: "https://kuma.example.com"},
{name: "url invalid", key: "UptimeKumaUrl", value: "kuma.example.com", wantErr: true},
{name: "scope all", key: "UptimeKumaMonitorScope", value: "all"},
{name: "scope selected", key: "UptimeKumaMonitorScope", value: "selected"},
{name: "scope invalid", key: "UptimeKumaMonitorScope", value: "none", wantErr: true},
{name: "sync interval valid", key: "UptimeKumaSyncInterval", value: "5"},
{name: "sync interval invalid", key: "UptimeKumaSyncInterval", value: "0", wantErr: true},
{name: "interval valid", key: "UptimeKumaInterval", value: "60"},
{name: "interval invalid", key: "UptimeKumaInterval", value: "-60", wantErr: true},
{name: "retry valid", key: "UptimeKumaRetry", value: "0"},
{name: "retry positive valid", key: "UptimeKumaRetry", value: "3"},
{name: "retry invalid", key: "UptimeKumaRetry", value: "-1", wantErr: true},
}
for _, tc := range testCases {
err := validateUptimeKumaOption(tc.key, tc.value, state)
if tc.wantErr && err == nil {
t.Fatalf("%s: expected error", tc.name)
}
if !tc.wantErr && err != nil {
t.Fatalf("%s: unexpected error: %v", tc.name, err)
}
}
// Test enabling Uptime Kuma when URL or credentials are empty in state
stateEmpty := map[string]string{
"UptimeKumaUrl": "",
"UptimeKumaUsername": "",
"UptimeKumaPassword": "",
}
if err := validateUptimeKumaOption("UptimeKumaEnabled", "true", stateEmpty); err == nil {
t.Fatal("expected error when enabling Uptime Kuma with empty URL/credentials in state")
}
}
+162
View File
@@ -0,0 +1,162 @@
package controller
import (
"openflare/service"
"github.com/gin-gonic/gin"
)
func ListPagesProjects(c *gin.Context) {
projects, err := service.ListPagesProjects()
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, projects)
}
func GetPagesProject(c *gin.Context) {
id, ok := parseIDParam(c)
if !ok {
return
}
project, err := service.GetPagesProject(id)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, project)
}
func CreatePagesProject(c *gin.Context) {
var input service.PagesProjectInput
if !bindJSON(c, &input) {
return
}
project, err := service.CreatePagesProject(input)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, project)
}
func UpdatePagesProject(c *gin.Context) {
id, ok := parseIDParam(c)
if !ok {
return
}
var input service.PagesProjectInput
if !bindJSON(c, &input) {
return
}
project, err := service.UpdatePagesProject(id, input)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, project)
}
func DeletePagesProject(c *gin.Context) {
id, ok := parseIDParam(c)
if !ok {
return
}
if err := service.DeletePagesProject(id); err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, nil)
}
func ListPagesDeployments(c *gin.Context) {
id, ok := parseIDParam(c)
if !ok {
return
}
deployments, err := service.ListPagesProjectDeployments(id)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, deployments)
}
func UploadPagesDeployment(c *gin.Context) {
id, ok := parseIDParam(c)
if !ok {
return
}
file, err := c.FormFile("package")
if err != nil {
respondBadRequest(c, "缺少 Pages 部署包")
return
}
deployment, err := service.UploadPagesDeployment(id, file, c.PostForm("entry_file"), c.GetString("username"))
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, deployment)
}
func ActivatePagesDeployment(c *gin.Context) {
projectID, ok := parseIDParam(c)
if !ok {
return
}
deploymentID, ok := parseIDParamByName(c, "deployment_id")
if !ok {
return
}
project, err := service.ActivatePagesDeployment(projectID, deploymentID)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, project)
}
func DeletePagesDeployment(c *gin.Context) {
projectID, ok := parseIDParam(c)
if !ok {
return
}
deploymentID, ok := parseIDParamByName(c, "deployment_id")
if !ok {
return
}
if err := service.DeletePagesDeployment(projectID, deploymentID); err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, nil)
}
func ListPagesDeploymentFiles(c *gin.Context) {
deploymentID, ok := parseIDParamByName(c, "deployment_id")
if !ok {
return
}
files, err := service.ListPagesDeploymentFiles(deploymentID)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, files)
}
func AgentDownloadPagesDeploymentPackage(c *gin.Context) {
deploymentID, ok := parseIDParamByName(c, "deployment_id")
if !ok {
return
}
filePath, fileName, err := service.GetPagesDeploymentPackagePath(deploymentID)
if err != nil {
respondFailure(c, err.Error())
return
}
c.Header("Content-Disposition", "attachment; filename="+fileName)
c.File(filePath)
}
+5 -1
View File
@@ -75,7 +75,11 @@ func decodeOptionalJSONBody(body io.Reader, target any) error {
}
func parseIDParam(c *gin.Context) (uint, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
return parseIDParamByName(c, "id")
}
func parseIDParamByName(c *gin.Context, name string) (uint, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
respondBadRequest(c, "")
return 0, false
+24
View File
@@ -0,0 +1,24 @@
package controller
import (
"openflare/service"
"github.com/gin-gonic/gin"
)
// SyncUptimeKuma godoc
// @Summary Manually trigger Uptime Kuma sync
// @Tags UptimeKuma
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/uptimekuma/sync [post]
func SyncUptimeKuma(c *gin.Context) {
err := service.SyncToUptimeKuma()
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccessMessage(c, "同步成功")
}
+11 -7
View File
@@ -1,7 +1,6 @@
module openflare
// +heroku goVersion go1.24
go 1.25.0
go 1.25.7
require (
github.com/bwmarrin/snowflake v0.3.0
@@ -17,6 +16,7 @@ require (
github.com/go-redis/redis/v8 v8.11.5
github.com/google/uuid v1.6.0
github.com/oschwald/maxminddb-golang v1.13.1
github.com/pressly/goose/v3 v3.27.1
github.com/robfig/cron/v3 v3.0.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
@@ -57,7 +57,7 @@ require (
github.com/gorilla/sessions v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -68,13 +68,17 @@ require (
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
@@ -85,8 +89,8 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+48 -15
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
@@ -87,8 +89,8 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
github.com/go-playground/validator/v10 v10.23.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-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@@ -99,8 +101,8 @@ github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
@@ -110,12 +112,14 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -152,6 +156,8 @@ github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -161,6 +167,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
@@ -175,13 +183,16 @@ github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
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=
@@ -208,6 +219,8 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
@@ -287,13 +300,33 @@ gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
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=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+8
View File
@@ -26,6 +26,14 @@ func InitCronJobs() {
slog.Info("registered WAF IP group sync cron job")
}
// Register Uptime Kuma sync job (check every minute)
_, err = cronRunner.AddJob("* * * * *", &UptimeKumaSyncJob{})
if err != nil {
slog.Error("failed to register Uptime Kuma sync cron job", "error", err)
} else {
slog.Info("registered Uptime Kuma sync cron job")
}
cronRunner.Start()
}
+43
View File
@@ -0,0 +1,43 @@
package job
import (
"log/slog"
"openflare/common"
"openflare/service"
"sync"
"time"
)
var lastUptimeKumaSyncTime time.Time
var uptimeKumaSyncMutex sync.Mutex
type UptimeKumaSyncJob struct{}
func (j *UptimeKumaSyncJob) Run() {
if !common.UptimeKumaEnabled {
return
}
interval := common.UptimeKumaSyncInterval
if interval <= 0 {
interval = 5
}
if time.Since(lastUptimeKumaSyncTime) < time.Duration(interval)*time.Minute {
return
}
if !uptimeKumaSyncMutex.TryLock() {
slog.Warn("Uptime Kuma sync job is already running, skipping this scheduled run")
return
}
defer uptimeKumaSyncMutex.Unlock()
slog.Info("Starting scheduled Uptime Kuma sync")
if err := service.SyncToUptimeKuma(); err != nil {
slog.Error("Uptime Kuma sync failed", "error", err)
} else {
lastUptimeKumaSyncTime = time.Now()
slog.Info("Uptime Kuma sync completed successfully")
}
}
@@ -6,11 +6,14 @@ import (
)
const (
legacyDatabaseSchemaVersion = migrate.BaseDatabaseSchemaVersion
databaseSchemaVersionRowID = 1
legacyDatabaseSchemaVersion = migrate.BaseDatabaseSchemaVersion
legacyMigrationTerminalVersion = 17
databaseSchemaVersionRowID = 1
)
var currentDatabaseSchemaVersion = migrate.CurrentVersion()
// currentDatabaseSchemaVersion tracks the current physical schema validated by the
// legacy validator set. Goose owns only post-v17 migrations, and none exist yet.
var currentDatabaseSchemaVersion = legacyMigrationTerminalVersion
type DatabaseSchemaVersion struct {
ID uint `json:"id" gorm:"primaryKey"`
+223
View File
@@ -0,0 +1,223 @@
package goose
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"gorm.io/gorm"
)
type BridgeContext interface {
Context
AutoMigrateLegacySchemaMetadata(db *gorm.DB) error
InitializeFreshDatabaseSchema(db *gorm.DB, backend string) error
IsDatabaseEmpty(db *gorm.DB) (bool, error)
RepairCurrentSchemaState(db *gorm.DB, backend string) error
SaveLegacyDatabaseSchemaVersion(db *gorm.DB, version int) error
UpgradeLegacyDatabaseSchema(db *gorm.DB, backend string, version int) error
ValidateCurrentDatabaseSchema(db *gorm.DB, backend string) error
}
type schemaMigrationState int
const (
schemaMigrationStateFresh schemaMigrationState = iota
schemaMigrationStateLegacyOnly
schemaMigrationStateGooseOnly
schemaMigrationStateLegacyBootstrap
schemaMigrationStateMixed
)
func detectSchemaState(db *gorm.DB, ctx BridgeContext) (schemaMigrationState, error) {
hasLegacyTable := db.Migrator().HasTable("database_schema_versions")
hasGooseTable := db.Migrator().HasTable("goose_db_version")
switch {
case hasLegacyTable && hasGooseTable:
return schemaMigrationStateMixed, nil
case hasLegacyTable:
return schemaMigrationStateLegacyOnly, nil
case hasGooseTable:
return schemaMigrationStateGooseOnly, nil
}
empty, err := ctx.IsDatabaseEmpty(db)
if err != nil {
return 0, err
}
if empty {
return schemaMigrationStateFresh, nil
}
return schemaMigrationStateLegacyBootstrap, nil
}
func LoadDatabaseVersion(db *gorm.DB) (int, bool, error) {
if db == nil || !db.Migrator().HasTable("goose_db_version") {
return 0, false, nil
}
var version int64
err := db.Table("goose_db_version").
Where("is_applied = ?", true).
Order("version_id DESC").
Select("version_id").
Limit(1).
Row().
Scan(&version)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return int(version), true, nil
}
func loadLegacyDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
if db == nil || !db.Migrator().HasTable("database_schema_versions") {
return 0, false, nil
}
var version int
err := db.Table("database_schema_versions").
Where("id = ?", 1).
Select("version").
Limit(1).
Row().
Scan(&version)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return version, true, nil
}
func bootstrapLegacySchemaVersion(db *gorm.DB, ctx BridgeContext) error {
if err := ctx.AutoMigrateLegacySchemaMetadata(db); err != nil {
return err
}
version, exists, err := loadLegacyDatabaseSchemaVersion(db)
if err != nil {
return err
}
if exists {
if int64(version) > LegacyBridgeVersion {
return fmt.Errorf("legacy schema version %d is newer than supported terminal version %d", version, LegacyBridgeVersion)
}
return nil
}
return ctx.SaveLegacyDatabaseSchemaVersion(db, 7)
}
func upgradeLegacyToTerminal(db *gorm.DB, backend string, ctx BridgeContext) error {
if err := bootstrapLegacySchemaVersion(db, ctx); err != nil {
return err
}
version, exists, err := loadLegacyDatabaseSchemaVersion(db)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("legacy schema version record is missing after bootstrap")
}
return ctx.UpgradeLegacyDatabaseSchema(db, backend, version)
}
func validateGooseBridgeState(db *gorm.DB) error {
version, exists, err := LoadDatabaseVersion(db)
if err != nil {
return err
}
if !exists {
return nil
}
if int64(version) < LegacyBridgeVersion {
return fmt.Errorf("goose schema version %d is below legacy bridge baseline %d", version, LegacyBridgeVersion)
}
if int64(version) > CurrentTargetVersion() {
return fmt.Errorf("goose schema version %d is newer than application target version %d", version, CurrentTargetVersion())
}
return nil
}
func finalizeLegacyToGooseBridge(db *gorm.DB) error {
gooseVersion, exists, err := LoadDatabaseVersion(db)
if err != nil {
return err
}
if !exists || int64(gooseVersion) < LegacyBridgeVersion {
return nil
}
if !db.Migrator().HasTable("database_schema_versions") {
return nil
}
if err := db.Exec("DROP TABLE IF EXISTS database_schema_versions").Error; err != nil {
return fmt.Errorf("drop legacy schema versions table failed: %w", err)
}
slog.Info("completed legacy-to-goose migration bridge", "goose_version", gooseVersion)
return nil
}
func ValidateRegisteredSchema(db *gorm.DB) error {
if err := validateNodeCapabilitiesJSON(db); err != nil {
return err
}
return nil
}
func EnsureDatabaseSchemaUpToDate(db *gorm.DB, backend string, ctx BridgeContext) error {
state, err := detectSchemaState(db, ctx)
if err != nil {
return err
}
switch state {
case schemaMigrationStateFresh:
if err := ctx.InitializeFreshDatabaseSchema(db, backend); err != nil {
return err
}
case schemaMigrationStateLegacyOnly:
if err := upgradeLegacyToTerminal(db, backend, ctx); err != nil {
return err
}
case schemaMigrationStateGooseOnly:
if err := validateGooseBridgeState(db); err != nil {
return err
}
case schemaMigrationStateLegacyBootstrap:
if err := upgradeLegacyToTerminal(db, backend, ctx); err != nil {
return err
}
case schemaMigrationStateMixed:
legacyVersion, exists, err := loadLegacyDatabaseSchemaVersion(db)
if err != nil {
return err
}
if exists && int64(legacyVersion) != LegacyBridgeVersion {
return fmt.Errorf("incomplete mixed migration state: legacy schema version %d does not match bridge terminal version %d", legacyVersion, LegacyBridgeVersion)
}
if err := validateGooseBridgeState(db); err != nil {
return err
}
default:
return fmt.Errorf("unknown schema migration state: %d", state)
}
if err := runMigrations(db, backend, ctx); err != nil {
return err
}
if err := finalizeLegacyToGooseBridge(db); err != nil {
return err
}
if err := ctx.RepairCurrentSchemaState(db, backend); err != nil {
return err
}
if err := ctx.ValidateCurrentDatabaseSchema(db, backend); err != nil {
return err
}
return ValidateRegisteredSchema(db)
}
@@ -0,0 +1,50 @@
package goose
import (
"encoding/json"
"fmt"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/gorm"
)
const versionNodeCapabilitiesJSON int64 = 202606020001
// migration202606020001 adds a future-proof JSON field for node capability
// summaries after the legacy v17 migration bridge.
func migration202606020001(backend string, ctx Context) *presslygoose.Migration {
return newGORMMigration(
versionNodeCapabilitiesJSON,
"202606020001_add_node_capabilities_json.go",
backend,
ctx,
migrateNodeCapabilitiesJSON,
)
}
func migrateNodeCapabilitiesJSON(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
return err
}
emptyJSON, err := json.Marshal([]string{})
if err != nil {
return fmt.Errorf("marshal default node capabilities: %w", err)
}
if err := db.Exec(
`UPDATE nodes SET capabilities_json = ? WHERE capabilities_json IS NULL OR TRIM(capabilities_json) = ''`,
string(emptyJSON),
).Error; err != nil {
return fmt.Errorf("backfill nodes.capabilities_json: %w", err)
}
return validateNodeCapabilitiesJSON(db)
}
func validateNodeCapabilitiesJSON(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasColumn("nodes", "capabilities_json") {
return fmt.Errorf("column nodes.capabilities_json is missing")
}
return nil
}
@@ -0,0 +1,61 @@
package goose
import (
"fmt"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/gorm"
)
const versionPagesStaticHosting int64 = 202606030001
// migration202606030001 adds OpenFlare Pages static hosting tables and the
// proxy_routes.pages_project_id binding used by the global release snapshot.
func migration202606030001(backend string, ctx Context) *presslygoose.Migration {
return newGORMMigration(
versionPagesStaticHosting,
"202606030001_add_pages_static_hosting.go",
backend,
ctx,
migratePagesStaticHosting,
)
}
func migratePagesStaticHosting(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
return err
}
if err := db.Exec(
`UPDATE proxy_routes SET upstream_type = 'direct' WHERE upstream_type IS NULL OR TRIM(upstream_type) = ''`,
).Error; err != nil {
return fmt.Errorf("backfill proxy_routes.upstream_type: %w", err)
}
return validatePagesStaticHosting(db)
}
func validatePagesStaticHosting(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
for _, table := range []string{"pages_projects", "pages_deployments", "pages_deployment_files"} {
if !db.Migrator().HasTable(table) {
return fmt.Errorf("table %s is missing", table)
}
}
for _, column := range []string{"upstream_type", "pages_project_id"} {
if !db.Migrator().HasColumn("proxy_routes", column) {
return fmt.Errorf("column proxy_routes.%s is missing", column)
}
}
for _, column := range []string{"slug", "active_deployment_id", "spa_fallback_enabled", "spa_fallback_path"} {
if !db.Migrator().HasColumn("pages_projects", column) {
return fmt.Errorf("column pages_projects.%s is missing", column)
}
}
for _, column := range []string{"project_id", "checksum", "artifact_path", "entry_file"} {
if !db.Migrator().HasColumn("pages_deployments", column) {
return fmt.Errorf("column pages_deployments.%s is missing", column)
}
}
return nil
}
@@ -0,0 +1,37 @@
package goose
import (
"fmt"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/gorm"
)
const versionPagesSPAFallbackPath int64 = 202606030002
// migration202606030002 adds a configurable SPA fallback path for Pages
// projects. Existing projects keep the previous /index.html behavior.
func migration202606030002(backend string, ctx Context) *presslygoose.Migration {
return newGORMMigration(
versionPagesSPAFallbackPath,
"202606030002_add_pages_spa_fallback_path.go",
backend,
ctx,
migratePagesSPAFallbackPath,
)
}
func migratePagesSPAFallbackPath(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
return err
}
if err := db.Exec(
`UPDATE pages_projects SET spa_fallback_path = '/index.html' WHERE spa_fallback_path IS NULL OR TRIM(spa_fallback_path) = ''`,
).Error; err != nil {
return fmt.Errorf("backfill pages_projects.spa_fallback_path: %w", err)
}
if !db.Migrator().HasColumn("pages_projects", "spa_fallback_path") {
return fmt.Errorf("column pages_projects.spa_fallback_path is missing")
}
return nil
}
@@ -0,0 +1,41 @@
package goose
import (
"fmt"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/gorm"
)
const versionDropProxyRouteLegacyPoW int64 = 202606030003
// migration202606030003 drops the legacy pow_enabled and pow_config columns
// from proxy_routes table, since PoW is now entirely managed under WAF rule groups.
func migration202606030003(backend string, ctx Context) *presslygoose.Migration {
return newGORMMigration(
versionDropProxyRouteLegacyPoW,
"202606030003_drop_proxy_route_legacy_pow.go",
backend,
ctx,
migrateDropProxyRouteLegacyPoW,
)
}
func migrateDropProxyRouteLegacyPoW(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
return err
}
// Drop pow_enabled column if exists
if db.Migrator().HasColumn("proxy_routes", "pow_enabled") {
if err := db.Exec("ALTER TABLE proxy_routes DROP COLUMN pow_enabled").Error; err != nil {
return fmt.Errorf("drop proxy_routes.pow_enabled: %w", err)
}
}
// Drop pow_config column if exists
if db.Migrator().HasColumn("proxy_routes", "pow_config") {
if err := db.Exec("ALTER TABLE proxy_routes DROP COLUMN pow_config").Error; err != nil {
return fmt.Errorf("drop proxy_routes.pow_config: %w", err)
}
}
return nil
}
@@ -0,0 +1,74 @@
package goose
import (
"context"
"database/sql"
"fmt"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/gorm"
)
const LegacyBridgeVersion int64 = 17
type migrationFunc func(ctx Context, db *gorm.DB, backend string) error
func newBaselineMigration() *presslygoose.Migration {
migration := presslygoose.NewGoMigration(LegacyBridgeVersion, nil, nil)
migration.Source = fmt.Sprintf("%05d_legacy_terminal_baseline.go", LegacyBridgeVersion)
return migration
}
func newGORMMigration(version int64, source string, backend string, ctx Context, up migrationFunc) *presslygoose.Migration {
migration := presslygoose.NewGoMigration(version, &presslygoose.GoFunc{
RunDB: func(_ context.Context, sqlDB *sql.DB) error {
gormDB, err := openGORMDB(ctx, sqlDB, backend)
if err != nil {
return err
}
if backend == "postgres" {
return gormDB.Transaction(func(tx *gorm.DB) error {
return up(ctx, tx, backend)
})
}
return up(ctx, gormDB, backend)
},
}, nil)
migration.Source = source
return migration
}
func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration {
return []*presslygoose.Migration{
migration202606020001(backend, ctx),
migration202606030001(backend, ctx),
migration202606030002(backend, ctx),
migration202606030003(backend, ctx),
}
}
func buildMigrations(backend string, ctx Context) []*presslygoose.Migration {
migrations := []*presslygoose.Migration{newBaselineMigration()}
migrations = append(migrations, registeredMigrations(backend, ctx)...)
return migrations
}
func CurrentTargetVersion() int64 {
var maxVersion int64 = LegacyBridgeVersion
for _, migration := range buildMigrations("sqlite", noopContext{}) {
if migration.Version > maxVersion {
maxVersion = migration.Version
}
}
return maxVersion
}
type noopContext struct{}
func (noopContext) ApplyCurrentSchema(db *gorm.DB, backend string) error {
return nil
}
func (noopContext) RegisterSharding(db *gorm.DB, backend string) error {
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package goose
import (
"context"
"database/sql"
"fmt"
"github.com/glebarez/sqlite"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
type Context interface {
ApplyCurrentSchema(db *gorm.DB, backend string) error
RegisterSharding(db *gorm.DB, backend string) error
}
func dialectForBackend(backend string) (presslygoose.Dialect, error) {
switch backend {
case "postgres":
return presslygoose.DialectPostgres, nil
case "sqlite":
return presslygoose.DialectSQLite3, nil
default:
return "", fmt.Errorf("unsupported database backend: %s", backend)
}
}
func openGORMDB(ctx Context, db *sql.DB, backend string) (*gorm.DB, error) {
var dialector gorm.Dialector
switch backend {
case "postgres":
dialector = postgres.New(postgres.Config{Conn: db})
case "sqlite":
dialector = &sqlite.Dialector{Conn: db}
default:
return nil, fmt.Errorf("unsupported database backend: %s", backend)
}
gormDB, err := gorm.Open(dialector, &gorm.Config{
NamingStrategy: schema.NamingStrategy{},
})
if err != nil {
return nil, err
}
if err := ctx.RegisterSharding(gormDB, backend); err != nil {
return nil, err
}
return gormDB, nil
}
func buildProvider(db *gorm.DB, backend string, ctx Context) (*presslygoose.Provider, error) {
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
dialect, err := dialectForBackend(backend)
if err != nil {
return nil, err
}
return presslygoose.NewProvider(
dialect,
sqlDB,
nil,
presslygoose.WithDisableGlobalRegistry(true),
presslygoose.WithGoMigrations(buildMigrations(backend, ctx)...),
)
}
func runMigrations(db *gorm.DB, backend string, ctx Context) error {
provider, err := buildProvider(db, backend, ctx)
if err != nil {
return fmt.Errorf("build goose provider: %w", err)
}
if _, err := provider.Up(context.Background()); err != nil {
return fmt.Errorf("goose up failed: %w", err)
}
return nil
}
+15 -1
View File
@@ -32,6 +32,9 @@ func registeredModels() []any {
&Option{},
&Origin{},
&ProxyRoute{},
&PagesProject{},
&PagesDeployment{},
&PagesDeploymentFile{},
&ConfigVersion{},
&Node{},
@@ -54,12 +57,23 @@ func registeredModels() []any {
}
}
func schemaMetadataModels() []any {
func currentSchemaMetadataModels() []any {
return nil
}
func legacySchemaMetadataModels() []any {
return []any{
&DatabaseSchemaVersion{},
}
}
func schemaMetadataModels() []any {
models := make([]any, 0, len(currentSchemaMetadataModels())+len(legacySchemaMetadataModels()))
models = append(models, currentSchemaMetadataModels()...)
models = append(models, legacySchemaMetadataModels()...)
return models
}
func buildDBModels() ([]dbModel, error) {
models := registeredModels()
result := make([]dbModel, 0, len(models))
+117 -13
View File
@@ -89,6 +89,10 @@ func findDBModelByTableName(t *testing.T, tableName string) dbModel {
return dbModel{}
}
func expectedCurrentDatabaseVersion() int {
return int(currentGooseTargetVersion())
}
func TestIsDatabaseEmpty(t *testing.T) {
db := openTestSQLiteDB(t, "empty.db")
@@ -195,12 +199,18 @@ func TestUpgradeDatabaseSchemaV15ToV16AppliesCompressedReleaseSchema(t *testing.
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)
if err := autoMigrateLegacySchemaMetadata(db); err != nil {
t.Fatalf("auto migrate legacy schema metadata: %v", err)
}
if err := applyCurrentSchema(db, "sqlite"); err != nil {
t.Fatalf("apply current schema: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
if err := ensureDefaultWAFRuleGroup(db); err != nil {
t.Fatalf("ensure default waf rule group: %v", err)
}
@@ -303,8 +313,17 @@ func TestEnsureDatabaseSchemaUpToDateInitializesFreshDatabase(t *testing.T) {
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)
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected fresh database to avoid legacy database_schema_versions table")
}
if !db.Migrator().HasTable("goose_db_version") {
t.Fatal("expected fresh database to initialize goose_db_version")
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected fresh database to apply goose migration nodes.capabilities_json")
}
}
@@ -316,6 +335,13 @@ func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) {
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
// Add legacy PoW columns manually to proxy_routes table to simulate legacy schema v9-v17 state
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
if err := db.Create(&User{
Username: "legacy",
Password: "secret",
@@ -337,8 +363,17 @@ func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) {
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)
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected legacy database_schema_versions table to be removed after bridging to goose")
}
if !db.Migrator().HasTable("goose_db_version") {
t.Fatal("expected legacy upgrade to initialize goose_db_version")
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected legacy upgrade to apply goose migration nodes.capabilities_json")
}
}
@@ -402,8 +437,8 @@ func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteDomainCertificateFields(t *te
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)
if err := autoMigrateLegacySchemaMetadata(db); err != nil {
t.Fatalf("auto migrate legacy schema metadata: %v", err)
}
for _, item := range registeredModels() {
@@ -417,6 +452,13 @@ func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteDomainCertificateFields(t *te
if err := db.AutoMigrate(&legacyProxyRouteV7{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes v7: %v", err)
}
// Add legacy PoW columns manually to proxy_routes table to simulate legacy schema v9-v17 state
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
now := time.Now().UTC()
certID := uint(9)
@@ -478,7 +520,7 @@ func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *t
fromVersion: legacyDatabaseSchemaVersion,
toVersion: 11,
migrate: func(tx *gorm.DB, backend string) error {
return autoMigrateSchemaMetadata(tx)
return autoMigrateLegacySchemaMetadata(tx)
},
validate: func(tx *gorm.DB, backend string) error {
return gorm.ErrInvalidDB
@@ -505,6 +547,12 @@ func TestEnsureDatabaseSchemaUpToDateAddsNodeIPManualOverride(t *testing.T) {
if err := applyCurrentSchema(db, "sqlite"); err != nil {
t.Fatalf("apply current schema: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
if err := ensureDefaultWAFRuleGroup(db); err != nil {
t.Fatalf("ensure default waf rule group: %v", err)
}
@@ -532,8 +580,11 @@ func TestEnsureDatabaseSchemaUpToDateAddsNodeIPManualOverride(t *testing.T) {
if !exists {
t.Fatal("expected schema version record to exist")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected migration chain to include nodes.capabilities_json")
}
}
@@ -545,6 +596,12 @@ func TestEnsureDatabaseSchemaUpToDateV16BackfillsNodeColumnsWhenNewColumnsAlread
if err := applyCurrentSchema(db, "sqlite"); err != nil {
t.Fatalf("apply current schema: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
if err := ensureDefaultWAFRuleGroup(db); err != nil {
t.Fatalf("ensure default waf rule group: %v", err)
}
@@ -616,8 +673,11 @@ func TestEnsureDatabaseSchemaUpToDateV16BackfillsNodeColumnsWhenNewColumnsAlread
if !exists {
t.Fatal("expected schema version record to exist")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected v16 upgrade path to apply goose migration nodes.capabilities_json")
}
}
@@ -655,6 +715,50 @@ func TestEnsureDatabaseSchemaUpToDateV16DropsLegacyNodeColumnsWhenAlreadyCurrent
t.Fatalf("expected current-schema cleanup to drop legacy nodes.%s column", column)
}
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected current-schema legacy version table to be removed after goose bridge")
}
if !db.Migrator().HasTable("goose_db_version") {
t.Fatal("expected current-schema goose_db_version table to exist")
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected current-schema repair to preserve goose column nodes.capabilities_json")
}
}
func TestEnsureDatabaseSchemaUpToDateKeepsGooseOnlyDatabaseOnReentry(t *testing.T) {
db := openBareTestSQLiteDB(t, "goose-only-reentry.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("first ensureDatabaseSchemaUpToDate: %v", err)
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected first initialization to avoid legacy table")
}
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("second ensureDatabaseSchemaUpToDate: %v", err)
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected goose-only database to remain free of legacy version table")
}
version, exists, err := loadGooseDatabaseVersion(db)
if err != nil {
t.Fatalf("loadGooseDatabaseVersion: %v", err)
}
if !exists {
t.Fatal("expected goose-only database to keep goose version record")
}
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected goose version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected goose-only database to keep nodes.capabilities_json")
}
}
func TestAllRegisteredMigrationsHaveValidationDefined(t *testing.T) {
+60 -44
View File
@@ -93,8 +93,17 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
}
}
func autoMigrateSchemaMetadata(db *gorm.DB) error {
for _, item := range schemaMetadataModels() {
func autoMigrateCurrentSchemaMetadata(db *gorm.DB) error {
for _, item := range currentSchemaMetadataModels() {
if err := db.AutoMigrate(item); err != nil {
return err
}
}
return nil
}
func autoMigrateLegacySchemaMetadata(db *gorm.DB) error {
for _, item := range legacySchemaMetadataModels() {
if err := db.AutoMigrate(item); err != nil {
return err
}
@@ -245,7 +254,7 @@ func applyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...str
}
}
slog.Info("applyCurrentSchema: step 1/5 - auto migrate schema metadata")
if err := autoMigrateSchemaMetadata(db); err != nil {
if err := autoMigrateCurrentSchemaMetadata(db); err != nil {
return err
}
slog.Info("applyCurrentSchema: step 2/5 - migrate proxy route https column")
@@ -268,7 +277,7 @@ func applyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...str
return nil
}
func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
func loadLegacyDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
if db == nil {
return 0, false, nil
}
@@ -286,19 +295,37 @@ func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
return state.Version, true, nil
}
func saveDatabaseSchemaVersion(db *gorm.DB, version int) error {
func saveLegacyDatabaseSchemaVersion(db *gorm.DB, version int) error {
if err := autoMigrateLegacySchemaMetadata(db); err != nil {
return err
}
return db.Save(&DatabaseSchemaVersion{
ID: databaseSchemaVersionRowID,
Version: version,
}).Error
}
func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
version, exists, err := loadGooseDatabaseVersion(db)
if err != nil {
return 0, false, err
}
if exists {
return version, true, nil
}
return loadLegacyDatabaseSchemaVersion(db)
}
func saveDatabaseSchemaVersion(db *gorm.DB, version int) error {
return saveLegacyDatabaseSchemaVersion(db, version)
}
func validateDatabaseSchemaV2(db *gorm.DB, backend string) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
return fmt.Errorf("table %s is missing", (&DatabaseSchemaVersion{}).TableName())
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) && !db.Migrator().HasTable("goose_db_version") {
return fmt.Errorf("neither %s nor goose_db_version exists", (&DatabaseSchemaVersion{}).TableName())
}
models, err := buildDBModels()
if err != nil {
@@ -1104,11 +1131,23 @@ func validateDatabaseSchemaV9(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV8(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_enabled") {
return fmt.Errorf("column proxy_routes.pow_enabled is missing")
hasAppliedDropPoW := false
if db.Migrator().HasTable("goose_db_version") {
var count int64
_ = db.Table("goose_db_version").
Where("version_id = ? AND is_applied = ?", 202606030003, true).
Count(&count).Error
if count > 0 {
hasAppliedDropPoW = true
}
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_config") {
return fmt.Errorf("column proxy_routes.pow_config is missing")
if !hasAppliedDropPoW {
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_enabled") {
return fmt.Errorf("column proxy_routes.pow_enabled is missing")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_config") {
return fmt.Errorf("column proxy_routes.pow_config is missing")
}
}
return nil
}
@@ -1385,7 +1424,7 @@ func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseS
if err := migration.validate(db, backend); err != nil {
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
}
if err := saveDatabaseSchemaVersion(db, migration.toVersion); err != nil {
if err := saveLegacyDatabaseSchemaVersion(db, migration.toVersion); err != nil {
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
}
return nil
@@ -1398,26 +1437,26 @@ func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseS
if err := migration.validate(tx, backend); err != nil {
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
}
if err := saveDatabaseSchemaVersion(tx, migration.toVersion); err != nil {
if err := saveLegacyDatabaseSchemaVersion(tx, migration.toVersion); err != nil {
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
}
return nil
})
}
func upgradeDatabaseSchema(db *gorm.DB, backend string, version int) error {
if version > currentDatabaseSchemaVersion {
return fmt.Errorf("database schema version %d is newer than application version %d", version, currentDatabaseSchemaVersion)
func upgradeLegacyDatabaseSchema(db *gorm.DB, backend string, version int) error {
if version > legacyMigrationTerminalVersion {
return fmt.Errorf("database schema version %d is newer than legacy migration terminal version %d", version, legacyMigrationTerminalVersion)
}
if version < legacyDatabaseSchemaVersion {
slog.Warn("database schema version is below supported baseline; treating it as historical initial schema", "version", version, "baseline", legacyDatabaseSchemaVersion)
version = legacyDatabaseSchemaVersion
}
if version == currentDatabaseSchemaVersion {
if version == legacyMigrationTerminalVersion {
return nil
}
migrationMap := databaseSchemaMigrationMap()
for version < currentDatabaseSchemaVersion {
for version < legacyMigrationTerminalVersion {
migration, ok := migrationMap[version]
if !ok {
return fmt.Errorf("database schema migration from v%d is not defined", version)
@@ -1458,32 +1497,9 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
if err := ensureDefaultWAFRuleGroup(db); err != nil {
return err
}
if err := validateCurrentDatabaseSchema(db, backend); err != nil {
return err
}
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
return nil
}
func ensureDatabaseSchemaUpToDate(db *gorm.DB, backend string) error {
version, exists, err := loadDatabaseSchemaVersion(db)
if err != nil {
return err
}
if exists {
if err := upgradeDatabaseSchema(db, backend, version); err != nil {
return err
}
return dropLegacyNodeColumns(db, backend)
}
empty, err := isDatabaseEmpty(db)
if err != nil {
return err
}
if empty {
return initializeFreshDatabaseSchema(db, backend)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
return err
}
return upgradeDatabaseSchema(db, backend, legacyDatabaseSchemaVersion)
func upgradeDatabaseSchema(db *gorm.DB, backend string, version int) error {
return upgradeLegacyDatabaseSchema(db, backend, version)
}
+1
View File
@@ -37,6 +37,7 @@ type Node struct {
RelayAgentAccessAddr string `json:"relay_agent_access_addr" gorm:"size:255"`
RelayClientAccessAddr string `json:"relay_client_access_addr" gorm:"size:255"`
RelayClientProxyURL string `json:"relay_client_proxy_url" gorm:"size:512"`
CapabilitiesJSON string `json:"capabilities_json" gorm:"type:text;not null;default:'[]'"`
RelayStatus string `json:"relay_status" gorm:"size:16;not null;default:'unknown'"`
RelayWebServerEnabled bool `json:"relay_web_server_enabled" gorm:"not null;default:false"`
}
+49
View File
@@ -52,6 +52,17 @@ func InitOptionMap() {
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
common.OptionMap["GeoIPProvider"] = common.GeoIPProvider
common.OptionMap["DatabaseAutoCleanupEnabled"] = strconv.FormatBool(common.DatabaseAutoCleanupEnabled)
common.OptionMap["UptimeKumaEnabled"] = strconv.FormatBool(common.UptimeKumaEnabled)
common.OptionMap["UptimeKumaUrl"] = common.UptimeKumaUrl
common.OptionMap["UptimeKumaUsername"] = common.UptimeKumaUsername
common.OptionMap["UptimeKumaPassword"] = common.UptimeKumaPassword
common.OptionMap["UptimeKumaMonitorScope"] = common.UptimeKumaMonitorScope
common.OptionMap["UptimeKumaSelectedSites"] = common.UptimeKumaSelectedSites
common.OptionMap["UptimeKumaSyncInterval"] = strconv.Itoa(common.UptimeKumaSyncInterval)
common.OptionMap["UptimeKumaInterval"] = strconv.Itoa(common.UptimeKumaInterval)
common.OptionMap["UptimeKumaRetry"] = strconv.Itoa(common.UptimeKumaRetry)
common.OptionMap["UptimeKumaRetryInterval"] = strconv.Itoa(common.UptimeKumaRetryInterval)
common.OptionMap["UptimeKumaTimeout"] = strconv.Itoa(common.UptimeKumaTimeout)
common.OptionMap["DatabaseAutoCleanupRetentionDays"] = strconv.Itoa(common.DatabaseAutoCleanupRetentionDays)
common.OptionMap["OpenRestyDefaultServerReturnStatus"] = strconv.Itoa(common.OpenRestyDefaultServerReturnStatus)
common.OptionMap["OpenRestyWorkerProcesses"] = common.OpenRestyWorkerProcesses
@@ -116,6 +127,9 @@ func UpdateOptions(options []Option) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
for _, item := range options {
if item.Key == "UptimeKumaPassword" && strings.TrimSpace(item.Value) == "" {
continue
}
option := Option{
Key: item.Key,
}
@@ -133,6 +147,9 @@ func UpdateOptions(options []Option) error {
}
for _, item := range options {
if item.Key == "UptimeKumaPassword" && strings.TrimSpace(item.Value) == "" {
continue
}
updateOptionMap(item.Key, item.Value)
}
return nil
@@ -209,6 +226,38 @@ func updateOptionMap(key string, value string) {
common.GeoIPProvider = value
shouldRefreshGeoIP = true
}
case "UptimeKumaEnabled":
common.UptimeKumaEnabled = value == "true"
case "UptimeKumaUrl":
common.UptimeKumaUrl = value
case "UptimeKumaUsername":
common.UptimeKumaUsername = value
case "UptimeKumaPassword":
common.UptimeKumaPassword = value
case "UptimeKumaMonitorScope":
common.UptimeKumaMonitorScope = value
case "UptimeKumaSelectedSites":
common.UptimeKumaSelectedSites = value
case "UptimeKumaSyncInterval":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaSyncInterval = v
}
case "UptimeKumaInterval":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaInterval = v
}
case "UptimeKumaRetry":
if v, err := strconv.Atoi(value); err == nil && v >= 0 {
common.UptimeKumaRetry = v
}
case "UptimeKumaRetryInterval":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaRetryInterval = v
}
case "UptimeKumaTimeout":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaTimeout = v
}
case "DatabaseAutoCleanupEnabled":
common.DatabaseAutoCleanupEnabled = value == "true"
case "DatabaseAutoCleanupRetentionDays":
+78
View File
@@ -0,0 +1,78 @@
package model
import "time"
const (
PagesDeploymentStatusUploaded = "uploaded"
PagesDeploymentStatusActive = "active"
)
type PagesProject struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"`
Slug string `json:"slug" gorm:"uniqueIndex;size:128;not null"`
Description string `json:"description" gorm:"type:text;not null;default:''"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled" gorm:"not null;default:false"`
SPAFallbackPath string `json:"spa_fallback_path" gorm:"size:512;not null;default:'/index.html'"`
ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PagesDeployment struct {
ID uint `json:"id" gorm:"primaryKey"`
ProjectID uint `json:"project_id" gorm:"not null;index"`
DeploymentNumber int `json:"deployment_number" gorm:"not null"`
Checksum string `json:"checksum" gorm:"size:64;not null;index"`
Status string `json:"status" gorm:"size:32;not null;default:'uploaded';index"`
ArtifactPath string `json:"artifact_path" gorm:"size:2048;not null"`
FileCount int `json:"file_count" gorm:"not null;default:0"`
TotalSize int64 `json:"total_size" gorm:"not null;default:0"`
EntryFile string `json:"entry_file" gorm:"size:512;not null;default:'index.html'"`
CreatedBy string `json:"created_by" gorm:"size:64;not null;default:''"`
CreatedAt time.Time `json:"created_at"`
ActivatedAt *time.Time `json:"activated_at"`
}
type PagesDeploymentFile struct {
ID uint `json:"id" gorm:"primaryKey"`
DeploymentID uint `json:"deployment_id" gorm:"not null;index"`
Path string `json:"path" gorm:"size:2048;not null"`
Size int64 `json:"size" gorm:"not null;default:0"`
Checksum string `json:"checksum" gorm:"size:64;not null"`
CreatedAt time.Time `json:"created_at"`
}
func ListPagesProjects() (projects []*PagesProject, err error) {
err = DB.Order("id desc").Find(&projects).Error
return projects, err
}
func GetPagesProjectByID(id uint) (*PagesProject, error) {
project := &PagesProject{}
err := DB.First(project, id).Error
return project, err
}
func GetPagesProjectBySlug(slug string) (*PagesProject, error) {
project := &PagesProject{}
err := DB.Where("slug = ?", slug).First(project).Error
return project, err
}
func ListPagesDeployments(projectID uint) (deployments []*PagesDeployment, err error) {
err = DB.Where("project_id = ?", projectID).Order("id desc").Find(&deployments).Error
return deployments, err
}
func GetPagesDeploymentByID(id uint) (*PagesDeployment, error) {
deployment := &PagesDeployment{}
err := DB.First(deployment, id).Error
return deployment, err
}
func ListPagesDeploymentFiles(deploymentID uint) (files []*PagesDeploymentFile, err error) {
err = DB.Where("deployment_id = ?", deploymentID).Order("path asc").Find(&files).Error
return files, err
}
+2 -4
View File
@@ -24,8 +24,6 @@ type ProxyRoute struct {
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:'{}'"`
BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"`
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"`
@@ -34,6 +32,7 @@ type ProxyRoute struct {
TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"`
TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"`
TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"`
PagesProjectID *uint `json:"pages_project_id" gorm:"index"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -85,8 +84,6 @@ func (route *ProxyRoute) Update() error {
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"pow_enabled": route.PoWEnabled,
"pow_config": route.PoWConfig,
"basic_auth_enabled": route.BasicAuthEnabled,
"basic_auth_username": route.BasicAuthUsername,
"basic_auth_password": route.BasicAuthPassword,
@@ -95,6 +92,7 @@ func (route *ProxyRoute) Update() error {
"tunnel_node_id": route.TunnelNodeID,
"tunnel_target_addr": route.TunnelTargetAddr,
"tunnel_target_protocol": route.TunnelTargetProtocol,
"pages_project_id": route.PagesProjectID,
}).Error
}
@@ -0,0 +1,60 @@
package model
import (
schemagoose "openflare/model/goose"
"gorm.io/gorm"
)
func currentGooseTargetVersion() int64 {
return schemagoose.CurrentTargetVersion()
}
func loadGooseDatabaseVersion(db *gorm.DB) (int, bool, error) {
return schemagoose.LoadDatabaseVersion(db)
}
func ensureDatabaseSchemaUpToDate(db *gorm.DB, backend string) error {
return schemagoose.EnsureDatabaseSchemaUpToDate(db, backend, databaseSchemaMigrationContext{})
}
func (databaseSchemaMigrationContext) RegisterSharding(db *gorm.DB, backend string) error {
return registerSharding(db, backend)
}
func (databaseSchemaMigrationContext) AutoMigrateLegacySchemaMetadata(db *gorm.DB) error {
return autoMigrateLegacySchemaMetadata(db)
}
func (databaseSchemaMigrationContext) InitializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
return initializeFreshDatabaseSchema(db, backend)
}
func (databaseSchemaMigrationContext) IsDatabaseEmpty(db *gorm.DB) (bool, error) {
return isDatabaseEmpty(db)
}
func (databaseSchemaMigrationContext) RepairCurrentSchemaState(db *gorm.DB, backend string) error {
if err := dropLegacyNodeColumns(db, backend); err != nil {
return err
}
if err := ensureDefaultGitHubAuthSource(db); err != nil {
return err
}
if err := ensureDefaultWAFRuleGroup(db); err != nil {
return err
}
return nil
}
func (databaseSchemaMigrationContext) SaveLegacyDatabaseSchemaVersion(db *gorm.DB, version int) error {
return saveLegacyDatabaseSchemaVersion(db, version)
}
func (databaseSchemaMigrationContext) UpgradeLegacyDatabaseSchema(db *gorm.DB, backend string, version int) error {
return upgradeLegacyDatabaseSchema(db, backend, version)
}
func (databaseSchemaMigrationContext) ValidateCurrentDatabaseSchema(db *gorm.DB, backend string) error {
return validateCurrentDatabaseSchema(db, backend)
}
+20
View File
@@ -67,6 +67,11 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
}
uptimekumaRoute := apiRouter.Group("/uptimekuma")
uptimekumaRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
uptimekumaRoute.POST("/sync", controller.SyncUptimeKuma)
}
authSourceRoute := apiRouter.Group("/auth-sources")
authSourceRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
@@ -122,6 +127,20 @@ func SetApiRouter(router *gin.Engine) {
originRoute.POST("/:id/update", controller.UpdateOrigin)
originRoute.POST("/:id/delete", controller.DeleteOrigin)
}
pagesRoute := apiRouter.Group("/pages")
pagesRoute.Use(middleware.AdminAuth())
{
pagesRoute.GET("/", controller.ListPagesProjects)
pagesRoute.GET("/:id", controller.GetPagesProject)
pagesRoute.POST("/", controller.CreatePagesProject)
pagesRoute.POST("/:id/update", controller.UpdatePagesProject)
pagesRoute.POST("/:id/delete", controller.DeletePagesProject)
pagesRoute.GET("/:id/deployments", controller.ListPagesDeployments)
pagesRoute.POST("/:id/deployments/upload", controller.UploadPagesDeployment)
pagesRoute.POST("/:id/deployments/:deployment_id/activate", controller.ActivatePagesDeployment)
pagesRoute.POST("/:id/deployments/:deployment_id/delete", controller.DeletePagesDeployment)
pagesRoute.GET("/deployments/:deployment_id/files", controller.ListPagesDeploymentFiles)
}
managedDomainRoute := apiRouter.Group("/managed-domains")
managedDomainRoute.Use(middleware.AdminAuth())
{
@@ -222,6 +241,7 @@ func SetApiRouter(router *gin.Engine) {
authorizedRoute.GET("/ws", controller.AgentWebSocket)
authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
authorizedRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
authorizedRoute.GET("/pages/deployments/:deployment_id/package", controller.AgentDownloadPagesDeploymentPackage)
authorizedRoute.POST("/waf/ip-groups/sync", controller.AgentSyncWAFIPGroups)
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
}
@@ -0,0 +1,454 @@
package router_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"openflare/common"
"openflare/model"
"openflare/router"
"strings"
"sync"
"testing"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
// mockKumaServer simulates Uptime Kuma's Engine.IO/Socket.IO polling endpoints
type mockKumaServer struct {
mu sync.Mutex
postsReceived []string
pendingPackets chan string
monitorList string // JSON representing map[string]UptimeKumaMonitor
}
func newMockKumaServer(monitorList string) *mockKumaServer {
return &mockKumaServer{
pendingPackets: make(chan string, 100),
monitorList: monitorList,
}
}
func (s *mockKumaServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
transport := r.URL.Query().Get("transport")
sid := r.URL.Query().Get("sid")
if r.Method == "GET" {
if transport == "polling" && sid == "" {
// Handshake response
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
_, _ = w.Write([]byte(`0{"sid":"mock-sid"}`))
return
}
if transport == "polling" && sid == "mock-sid" {
// Long-polling GET request
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
select {
case pkt := <-s.pendingPackets:
_, _ = w.Write([]byte(pkt))
case <-time.After(100 * time.Millisecond):
_, _ = w.Write([]byte(""))
}
return
}
} else if r.Method == "POST" {
bodyBytes, _ := io.ReadAll(r.Body)
bodyStr := string(bodyBytes)
s.postsReceived = append(s.postsReceived, bodyStr)
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
w.WriteHeader(http.StatusOK)
if bodyStr == "40" {
// Namespace Connect event
// Immediately queue the monitorList payload to be fetched by the next GET poll
s.pendingPackets <- fmt.Sprintf(`42["monitorList",%s]`, s.monitorList)
return
}
if strings.HasPrefix(bodyStr, "42") {
// Socket.IO message: 42<ackID>[...]
payload := bodyStr[2:]
// Find ack ID (digits at the start of payload)
digitsEnd := 0
for digitsEnd < len(payload) && payload[digitsEnd] >= '0' && payload[digitsEnd] <= '9' {
digitsEnd++
}
if digitsEnd == 0 {
return
}
ackIDStr := payload[:digitsEnd]
jsonArrayStr := payload[digitsEnd:]
var arr []json.RawMessage
if err := json.Unmarshal([]byte(jsonArrayStr), &arr); err != nil || len(arr) == 0 {
return
}
var eventName string
_ = json.Unmarshal(arr[0], &eventName)
switch eventName {
case "login", "loginByToken":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
case "getTags":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tags\":[{\"id\":10,\"name\":\"OpenFlare\",\"color\":\"#4f46e5\"}]}]", ackIDStr)
case "addTag":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tag\":{\"id\":10}}]", ackIDStr)
case "add":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"monitorID\":100}]", ackIDStr)
case "addMonitorTag":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
case "editMonitor":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
case "deleteMonitor":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
}
}
}
}
func TestUptimeKumaSyncDisabled(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)
loginCookie := loginAsRoot(t, engine)
// Keep integration disabled
common.UptimeKumaEnabled = false
// Request sync, should fail
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Success {
t.Fatal("expected sync request to fail when integration is disabled")
}
if !strings.Contains(resp.Message, "disabled") {
t.Fatalf("expected error message to mention integration is disabled, got: %s", resp.Message)
}
}
func TestUptimeKumaSyncSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
// Clean up route table just in case
_ = model.DB.Where("1 = 1").Delete(&model.ProxyRoute{}).Error
// Seed proxy routes
// Route 1: site-a (exists in Uptime Kuma but has different check parameters - should trigger editMonitor)
routeA := &model.ProxyRoute{
SiteName: "site-a",
Domain: "site-a.com",
Domains: `["site-a.com"]`,
OriginURL: "http://10.0.0.1",
Enabled: true,
EnableHTTPS: false,
}
// Route 2: site-b (does not exist in Uptime Kuma - should trigger add & addMonitorTag)
routeB := &model.ProxyRoute{
SiteName: "site-b",
Domain: "site-b.com",
Domains: `["site-b.com"]`,
OriginURL: "https://10.0.0.2",
Enabled: true,
EnableHTTPS: true,
}
// Route 3: site-c (disabled locally - should NOT be processed/created)
routeC := &model.ProxyRoute{
SiteName: "site-c",
Domain: "site-c.com",
Domains: `["site-c.com"]`,
OriginURL: "http://10.0.0.3",
Enabled: false,
EnableHTTPS: false,
}
if err := model.DB.Create(routeA).Error; err != nil {
t.Fatalf("failed to seed routeA: %v", err)
}
if err := model.DB.Create(routeB).Error; err != nil {
t.Fatalf("failed to seed routeB: %v", err)
}
if err := model.DB.Create(routeC).Error; err != nil {
t.Fatalf("failed to seed routeC: %v", err)
}
// Prepare mock monitorList
// 1. "site-old": tagged with OpenFlare but doesn't exist locally anymore -> should trigger deleteMonitor
// 2. "site-a": matches routeA but has interval = 30 (default UptimeKumaInterval is 60) -> should trigger editMonitor
monitorListJSON := `{
"99": {
"id": 99,
"name": "site-old",
"url": "http://site-old.com",
"interval": 60,
"tags": [{"tag_id": 10, "name": "OpenFlare"}]
},
"98": {
"id": 98,
"name": "site-a",
"url": "http://site-a.com",
"interval": 30,
"tags": [{"tag_id": 10, "name": "OpenFlare"}]
}
}`
mockSrv := newMockKumaServer(monitorListJSON)
server := httptest.NewServer(mockSrv)
defer server.Close()
// Backup and set configs
oldEnabled := common.UptimeKumaEnabled
oldUrl := common.UptimeKumaUrl
oldUsername := common.UptimeKumaUsername
oldPassword := common.UptimeKumaPassword
oldScope := common.UptimeKumaMonitorScope
oldInterval := common.UptimeKumaInterval
oldRetry := common.UptimeKumaRetry
oldRetryInterval := common.UptimeKumaRetryInterval
oldTimeout := common.UptimeKumaTimeout
common.UptimeKumaEnabled = true
common.UptimeKumaUrl = server.URL
common.UptimeKumaUsername = "admin"
common.UptimeKumaPassword = "password"
common.UptimeKumaMonitorScope = "all"
common.UptimeKumaInterval = 60
common.UptimeKumaRetry = 0
common.UptimeKumaRetryInterval = 60
common.UptimeKumaTimeout = 48
defer func() {
common.UptimeKumaEnabled = oldEnabled
common.UptimeKumaUrl = oldUrl
common.UptimeKumaUsername = oldUsername
common.UptimeKumaPassword = oldPassword
common.UptimeKumaMonitorScope = oldScope
common.UptimeKumaInterval = oldInterval
common.UptimeKumaRetry = oldRetry
common.UptimeKumaRetryInterval = oldRetryInterval
common.UptimeKumaTimeout = oldTimeout
}()
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", recorder.Code, recorder.Body.String())
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Success {
t.Fatalf("sync request failed: %s", resp.Message)
}
mockSrv.mu.Lock()
posts := mockSrv.postsReceived
mockSrv.mu.Unlock()
// Verify events received
hasLogin := false
hasGetTags := false
hasAddSiteB := false
hasTagSiteB := false
hasEditSiteA := false
hasDeleteOld := false
for _, body := range posts {
if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) {
hasLogin = true
}
if strings.Contains(body, `"getTags"`) {
hasGetTags = true
}
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) && strings.Contains(body, `"https://site-b.com"`) {
hasAddSiteB = true
}
if strings.Contains(body, `"addMonitorTag"`) && strings.Contains(body, `10`) && strings.Contains(body, `100`) {
hasTagSiteB = true
}
if strings.Contains(body, `"editMonitor"`) && strings.Contains(body, `98`) && strings.Contains(body, `"site-a"`) && strings.Contains(body, `"interval":60`) {
hasEditSiteA = true
}
if strings.Contains(body, `"deleteMonitor"`) && strings.Contains(body, `99`) {
hasDeleteOld = true
}
}
if !hasLogin {
t.Error("expected login event to be called")
}
if !hasGetTags {
t.Error("expected getTags event to be called")
}
if !hasAddSiteB {
t.Error("expected site-b to be added")
}
if !hasTagSiteB {
t.Error("expected site-b to be tagged")
}
if !hasEditSiteA {
t.Error("expected site-a to be edited/updated")
}
if !hasDeleteOld {
t.Error("expected site-old to be deleted")
}
}
func TestUptimeKumaSyncSelectedScope(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
// Clean up route table
_ = model.DB.Where("1 = 1").Delete(&model.ProxyRoute{}).Error
// Seed proxy routes
// Route 1: site-a (enabled, in selected list)
routeA := &model.ProxyRoute{
SiteName: "site-a",
Domain: "site-a.com",
Domains: `["site-a.com"]`,
OriginURL: "http://10.0.0.1",
Enabled: true,
EnableHTTPS: false,
}
// Route 2: site-b (enabled, NOT in selected list)
routeB := &model.ProxyRoute{
SiteName: "site-b",
Domain: "site-b.com",
Domains: `["site-b.com"]`,
OriginURL: "http://10.0.0.2",
Enabled: true,
EnableHTTPS: false,
}
if err := model.DB.Create(routeA).Error; err != nil {
t.Fatalf("failed to seed routeA: %v", err)
}
if err := model.DB.Create(routeB).Error; err != nil {
t.Fatalf("failed to seed routeB: %v", err)
}
mockSrv := newMockKumaServer(`{}`)
server := httptest.NewServer(mockSrv)
defer server.Close()
// Backup and set configs
oldEnabled := common.UptimeKumaEnabled
oldUrl := common.UptimeKumaUrl
oldUsername := common.UptimeKumaUsername
oldPassword := common.UptimeKumaPassword
oldScope := common.UptimeKumaMonitorScope
oldSelected := common.UptimeKumaSelectedSites
common.UptimeKumaEnabled = true
common.UptimeKumaUrl = server.URL
common.UptimeKumaUsername = "admin"
common.UptimeKumaPassword = "password"
common.UptimeKumaMonitorScope = "selected"
common.UptimeKumaSelectedSites = "site-a" // site-b is excluded
defer func() {
common.UptimeKumaEnabled = oldEnabled
common.UptimeKumaUrl = oldUrl
common.UptimeKumaUsername = oldUsername
common.UptimeKumaPassword = oldPassword
common.UptimeKumaMonitorScope = oldScope
common.UptimeKumaSelectedSites = oldSelected
}()
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Success {
t.Fatalf("sync request failed: %s", resp.Message)
}
mockSrv.mu.Lock()
posts := mockSrv.postsReceived
mockSrv.mu.Unlock()
hasLogin := false
hasAddSiteA := false
hasAddSiteB := false
for _, body := range posts {
if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) {
hasLogin = true
}
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-a"`) {
hasAddSiteA = true
}
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) {
hasAddSiteB = true
}
}
if !hasLogin {
t.Error("expected login event to be called")
}
if !hasAddSiteA {
t.Error("expected site-a to be added")
}
if hasAddSiteB {
t.Error("expected site-b NOT to be added (not in selected scope)")
}
}
-66
View File
@@ -11,39 +11,6 @@ import (
"gorm.io/gorm"
)
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", false); 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" || file.Path == "waf_config.json" {
t.Fatalf("agent config should not receive rendered runtime config file %s", file.Path)
}
}
if !strings.Contains(activeConfig.SourceConfigJSON, `"pow_enabled":true`) {
t.Fatal("expected agent config source json to include PoW source configuration")
}
}
func TestGetActiveConfigForAgentIncludesWAFConfig(t *testing.T) {
setupServiceTestDB(t)
@@ -144,39 +111,6 @@ func TestChangedWAFIPGroupsForAgentReturnsChecksumDelta(t *testing.T) {
}
}
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", false); 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" {
t.Fatal("agent config should not receive rendered pow_config.json")
}
}
if !strings.Contains(activeConfig.SourceConfigJSON, `"session_ttl":600`) {
t.Fatalf("expected default PoW session TTL to be in source json, got %s", activeConfig.SourceConfigJSON)
}
}
func TestRegisterNodeWithAccessToken(t *testing.T) {
setupServiceTestDB(t)
+84 -45
View File
@@ -83,8 +83,6 @@ type snapshotRoute struct {
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"`
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
@@ -93,6 +91,20 @@ type snapshotRoute struct {
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"`
PagesProjectID *uint `json:"pages_project_id,omitempty"`
PagesDeployment *snapshotPagesDeployment `json:"pages_deployment,omitempty"`
}
type snapshotPagesDeployment struct {
ProjectID uint `json:"project_id"`
ProjectSlug string `json:"project_slug"`
DeploymentID uint `json:"deployment_id"`
DeploymentNumber int `json:"deployment_number"`
Checksum string `json:"checksum"`
EntryFile string `json:"entry_file"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
SPAFallbackPath string `json:"spa_fallback_path"`
LocalRoot string `json:"local_root"`
}
type snapshotWAFRuleGroup struct {
@@ -515,24 +527,28 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
var tunnelNodeID *uint
var tunnelTargetAddr string
var tunnelTargetProtocol string
var pagesProjectID *uint
var pagesDeployment *snapshotPagesDeployment
if upstreamType == "tunnel" {
originURL = resolveTunnelOpenRestyUpstreamURL()
upstreams = []string{originURL}
tunnelNodeID = route.TunnelNodeID
tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr)
tunnelTargetProtocol = normalizeTunnelTargetProtocol(route.TunnelTargetProtocol)
} else if upstreamType == "pages" {
deployment, err := buildSnapshotPagesDeployment(route.PagesProjectID)
if err != nil {
return nil, fmt.Errorf("路由 %s Pages 配置无效: %w", route.Domain, err)
}
originURL = fmt.Sprintf("openflare-pages://project/%d", deployment.ProjectID)
upstreams = []string{originURL}
pagesProjectID = route.PagesProjectID
pagesDeployment = deployment
}
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
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{
ID: route.ID,
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]),
@@ -554,8 +570,6 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
CustomHeaders: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
BasicAuthEnabled: route.BasicAuthEnabled,
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
@@ -564,11 +578,47 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
TunnelNodeID: tunnelNodeID,
TunnelTargetAddr: tunnelTargetAddr,
TunnelTargetProto: tunnelTargetProtocol,
PagesProjectID: pagesProjectID,
PagesDeployment: pagesDeployment,
})
}
return items, nil
}
func buildSnapshotPagesDeployment(projectID *uint) (*snapshotPagesDeployment, error) {
if projectID == nil || *projectID == 0 {
return nil, errors.New("pages_project_id is required")
}
project, err := model.GetPagesProjectByID(*projectID)
if err != nil {
return nil, err
}
if !project.Enabled {
return nil, errors.New("Pages 项目未启用")
}
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
return nil, errors.New("Pages 项目没有激活部署")
}
deployment, err := model.GetPagesDeploymentByID(*project.ActiveDeploymentID)
if err != nil {
return nil, err
}
if deployment.ProjectID != project.ID {
return nil, errors.New("Pages 激活部署不属于当前项目")
}
return &snapshotPagesDeployment{
ProjectID: project.ID,
ProjectSlug: project.Slug,
DeploymentID: deployment.ID,
DeploymentNumber: deployment.DeploymentNumber,
Checksum: deployment.Checksum,
EntryFile: deployment.EntryFile,
SPAFallbackEnabled: project.SPAFallbackEnabled,
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
LocalRoot: fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID),
}, nil
}
func resolveTunnelOpenRestyUpstreamURL() string {
relayNodes, err := model.ListNodesByType("tunnel_relay")
if err == nil && len(relayNodes) > 0 {
@@ -800,17 +850,6 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
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
}
if !routes[index].BasicAuthEnabled {
routes[index].BasicAuthUsername = ""
routes[index].BasicAuthPassword = ""
@@ -819,10 +858,18 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
if routes[index].UpstreamType == "tunnel" {
routes[index].TunnelTargetAddr = strings.TrimSpace(routes[index].TunnelTargetAddr)
routes[index].TunnelTargetProto = normalizeTunnelTargetProtocol(routes[index].TunnelTargetProto)
routes[index].PagesProjectID = nil
routes[index].PagesDeployment = nil
} else if routes[index].UpstreamType == "pages" {
routes[index].TunnelNodeID = nil
routes[index].TunnelTargetAddr = ""
routes[index].TunnelTargetProto = ""
} else {
routes[index].TunnelNodeID = nil
routes[index].TunnelTargetAddr = ""
routes[index].TunnelTargetProto = ""
routes[index].PagesProjectID = nil
routes[index].PagesDeployment = nil
}
}
return routes
@@ -849,7 +896,7 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo
}
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
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 || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
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.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintPtrEqual(left.PagesProjectID, right.PagesProjectID) || !snapshotPagesDeploymentEqual(left.PagesDeployment, right.PagesDeployment) || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
return false
}
if len(left.Domains) != len(right.Domains) {
@@ -884,10 +931,22 @@ func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
return false
}
}
if !snapshotPoWConfigEqual(left.PoWConfig, right.PoWConfig) {
return true
}
func snapshotPagesDeploymentEqual(left *snapshotPagesDeployment, right *snapshotPagesDeployment) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
leftJSON, err := json.Marshal(left)
if err != nil {
return false
}
return true
rightJSON, err := json.Marshal(right)
if err != nil {
return false
}
return string(leftJSON) == string(rightJSON)
}
func snapshotWAFConfigEqual(left snapshotWAFDocument, right snapshotWAFDocument) bool {
@@ -902,26 +961,6 @@ func snapshotWAFConfigEqual(left snapshotWAFDocument, right snapshotWAFDocument)
return string(leftJSON) == string(rightJSON)
}
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
+37 -24
View File
@@ -982,31 +982,31 @@ func TestPublishConfigVersionDetectsPoWChanges(t *testing.T) {
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")
if !strings.Contains(firstRelease.Version.SupportFilesJSON, `"path":"waf_config.json"`) {
t.Fatal("expected publish to include waf_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,
group, err := CreateWAFRuleGroup(WAFRuleGroupInput{
Name: "pow group",
Enabled: true,
BlockStatusCode: 418,
PoWEnabled: true,
PoWConfig: json.RawMessage(`{"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":[]}}`),
})
if err != nil {
t.Fatalf("UpdateProxyRoute failed: %v", err)
t.Fatalf("CreateWAFRuleGroup failed: %v", err)
}
if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{group.ID}); err != nil {
t.Fatalf("ReplaceWAFSiteRuleGroups 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)
if !diff.WAFConfigChanged {
t.Fatal("expected PoW change (via WAF Rule Group) to trigger WAF config change")
}
secondRelease, err := PublishConfigVersion("root", false)
@@ -1053,18 +1053,18 @@ func TestPublishConfigVersionDetectsPoWChanges(t *testing.T) {
if err := json.Unmarshal([]byte(secondRelease.Version.SupportFilesJSON), &supportFiles); err != nil {
t.Fatalf("failed to decode support files: %v", err)
}
foundPowSupportFile := false
foundWafSupportFile := false
for _, file := range supportFiles {
if file.Path != "pow_config.json" {
if file.Path != "waf_config.json" {
continue
}
foundPowSupportFile = true
foundWafSupportFile = true
if !strings.Contains(file.Content, `"difficulty":5`) {
t.Fatalf("expected pow support file to persist config, got %s", file.Content)
t.Fatalf("expected waf support file to persist pow config, got %s", file.Content)
}
}
if !foundPowSupportFile {
t.Fatal("expected publish to include pow_config.json support file")
if !foundWafSupportFile {
t.Fatal("expected publish to include waf_config.json support file")
}
}
@@ -1081,15 +1081,13 @@ func TestPublishConfigVersionRendersBasicAuthWithPoW(t *testing.T) {
t.Fatalf("CreateTLSCertificate failed: %v", err)
}
_, err = CreateProxyRoute(ProxyRouteInput{
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "xbot.example.com",
OriginURL: "http://c1:36185",
Enabled: true,
EnableHTTPS: true,
CertID: &certificate.ID,
RedirectHTTP: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":4,"algorithm":"fast","session_ttl":600,"challenge_ttl":300,"whitelist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]}}`,
BasicAuthEnabled: true,
BasicAuthUsername: "admin",
BasicAuthPassword: "123",
@@ -1098,6 +1096,21 @@ func TestPublishConfigVersionRendersBasicAuthWithPoW(t *testing.T) {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
group, err := CreateWAFRuleGroup(WAFRuleGroupInput{
Name: "pow group",
Enabled: true,
BlockStatusCode: 418,
PoWEnabled: true,
PoWConfig: json.RawMessage(`{"difficulty":4,"algorithm":"fast","session_ttl":600,"challenge_ttl":300,"whitelist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]}}`),
})
if err != nil {
t.Fatalf("CreateWAFRuleGroup failed: %v", err)
}
if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{group.ID}); err != nil {
t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err)
}
result, err := PublishConfigVersion("root", false)
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
+743
View File
@@ -0,0 +1,743 @@
package service
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"mime/multipart"
"openflare/common"
"openflare/model"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"gorm.io/gorm"
)
const (
pagesMaxDeploymentFiles = 1000
pagesMaxDeploymentBytes = 100 * 1024 * 1024
defaultPagesEntryFile = "index.html"
defaultPagesFallbackPath = "/index.html"
)
var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`)
type PagesProjectInput struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
SPAFallbackPath string `json:"spa_fallback_path"`
}
type PagesProjectView struct {
ID uint `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
SPAFallbackPath string `json:"spa_fallback_path"`
ActiveDeploymentID *uint `json:"active_deployment_id"`
ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"`
DeploymentCount int64 `json:"deployment_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PagesDeploymentView struct {
ID uint `json:"id"`
ProjectID uint `json:"project_id"`
DeploymentNumber int `json:"deployment_number"`
Checksum string `json:"checksum"`
Status string `json:"status"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
EntryFile string `json:"entry_file"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
ActivatedAt *time.Time `json:"activated_at"`
}
type PagesDeploymentFileView struct {
ID uint `json:"id"`
DeploymentID uint `json:"deployment_id"`
Path string `json:"path"`
Size int64 `json:"size"`
Checksum string `json:"checksum"`
CreatedAt time.Time `json:"created_at"`
}
type pagesDeploymentManifest struct {
Files []model.PagesDeploymentFile
FileCount int
TotalSize int64
EntryFile string
}
func ListPagesProjects() ([]*PagesProjectView, error) {
projects, err := model.ListPagesProjects()
if err != nil {
return nil, err
}
views := make([]*PagesProjectView, 0, len(projects))
for _, project := range projects {
view, err := buildPagesProjectView(project)
if err != nil {
return nil, err
}
views = append(views, view)
}
return views, nil
}
func GetPagesProject(id uint) (*PagesProjectView, error) {
project, err := model.GetPagesProjectByID(id)
if err != nil {
return nil, err
}
return buildPagesProjectView(project)
}
func CreatePagesProject(input PagesProjectInput) (*PagesProjectView, error) {
project, err := buildPagesProject(nil, input)
if err != nil {
return nil, err
}
if err = model.DB.Create(project).Error; err != nil {
if model.IsUniqueConstraintError(err) {
return nil, errors.New("Pages 项目标识已存在")
}
return nil, err
}
return buildPagesProjectView(project)
}
func UpdatePagesProject(id uint, input PagesProjectInput) (*PagesProjectView, error) {
project, err := model.GetPagesProjectByID(id)
if err != nil {
return nil, err
}
project, err = buildPagesProject(project, input)
if err != nil {
return nil, err
}
if err = model.DB.Model(project).Updates(map[string]any{
"name": project.Name,
"slug": project.Slug,
"description": project.Description,
"enabled": project.Enabled,
"spa_fallback_enabled": project.SPAFallbackEnabled,
"spa_fallback_path": project.SPAFallbackPath,
}).Error; err != nil {
if model.IsUniqueConstraintError(err) {
return nil, errors.New("Pages 项目标识已存在")
}
return nil, err
}
return buildPagesProjectView(project)
}
func DeletePagesProject(id uint) error {
project, err := model.GetPagesProjectByID(id)
if err != nil {
return err
}
var routeCount int64
if err = model.DB.Model(&model.ProxyRoute{}).Where("pages_project_id = ?", project.ID).Count(&routeCount).Error; err != nil {
return err
}
if routeCount > 0 {
return errors.New("Pages 项目已被规则引用,不能删除")
}
deployments, err := model.ListPagesDeployments(project.ID)
if err != nil {
return err
}
return model.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("deployment_id IN (?)", tx.Model(&model.PagesDeployment{}).Select("id").Where("project_id = ?", project.ID)).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", project.ID).Delete(&model.PagesDeployment{}).Error; err != nil {
return err
}
if err := tx.Delete(project).Error; err != nil {
return err
}
for _, deployment := range deployments {
_ = os.Remove(deployment.ArtifactPath)
}
return nil
})
}
func ListPagesProjectDeployments(projectID uint) ([]*PagesDeploymentView, error) {
if _, err := model.GetPagesProjectByID(projectID); err != nil {
return nil, err
}
deployments, err := model.ListPagesDeployments(projectID)
if err != nil {
return nil, err
}
views := make([]*PagesDeploymentView, 0, len(deployments))
for _, deployment := range deployments {
views = append(views, buildPagesDeploymentView(deployment))
}
return views, nil
}
func ListPagesDeploymentFiles(deploymentID uint) ([]*PagesDeploymentFileView, error) {
if _, err := model.GetPagesDeploymentByID(deploymentID); err != nil {
return nil, err
}
files, err := model.ListPagesDeploymentFiles(deploymentID)
if err != nil {
return nil, err
}
views := make([]*PagesDeploymentFileView, 0, len(files))
for _, file := range files {
views = append(views, &PagesDeploymentFileView{
ID: file.ID,
DeploymentID: file.DeploymentID,
Path: file.Path,
Size: file.Size,
Checksum: file.Checksum,
CreatedAt: file.CreatedAt,
})
}
return views, nil
}
func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, entryFile string, createdBy string) (*PagesDeploymentView, error) {
project, err := model.GetPagesProjectByID(projectID)
if err != nil {
return nil, err
}
if fileHeader == nil {
return nil, errors.New("缺少 Pages 部署包")
}
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
return nil, errors.New("Pages 部署包必须是 .zip 文件")
}
entryFile = normalizePagesEntryFile(entryFile)
tempPath, checksum, err := persistPagesUploadTemp(fileHeader)
if err != nil {
return nil, err
}
defer os.Remove(tempPath)
manifest, err := inspectPagesZip(tempPath, entryFile)
if err != nil {
return nil, err
}
artifactPath, err := pagesArtifactPath(project.Slug, checksum)
if err != nil {
return nil, err
}
if err = os.MkdirAll(filepath.Dir(artifactPath), 0o755); err != nil {
return nil, fmt.Errorf("创建 Pages 存储目录失败: %w", err)
}
if err = copyFile(tempPath, artifactPath); err != nil {
return nil, err
}
deployment := &model.PagesDeployment{}
err = model.DB.Transaction(func(tx *gorm.DB) error {
var maxNumber int
if err := tx.Model(&model.PagesDeployment{}).
Where("project_id = ?", project.ID).
Select("COALESCE(MAX(deployment_number), 0)").
Scan(&maxNumber).Error; err != nil {
return err
}
deployment = &model.PagesDeployment{
ProjectID: project.ID,
DeploymentNumber: maxNumber + 1,
Checksum: checksum,
Status: model.PagesDeploymentStatusUploaded,
ArtifactPath: artifactPath,
FileCount: manifest.FileCount,
TotalSize: manifest.TotalSize,
EntryFile: manifest.EntryFile,
CreatedBy: strings.TrimSpace(createdBy),
}
if err := tx.Create(deployment).Error; err != nil {
return err
}
for index := range manifest.Files {
manifest.Files[index].DeploymentID = deployment.ID
}
if len(manifest.Files) > 0 {
if err := tx.Create(&manifest.Files).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
_ = os.Remove(artifactPath)
return nil, err
}
return buildPagesDeploymentView(deployment), nil
}
func ActivatePagesDeployment(projectID uint, deploymentID uint) (*PagesProjectView, error) {
project, err := model.GetPagesProjectByID(projectID)
if err != nil {
return nil, err
}
deployment, err := model.GetPagesDeploymentByID(deploymentID)
if err != nil {
return nil, err
}
if deployment.ProjectID != project.ID {
return nil, errors.New("Pages 部署不属于该项目")
}
now := time.Now()
if err = model.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.PagesDeployment{}).
Where("project_id = ?", project.ID).
Update("status", model.PagesDeploymentStatusUploaded).Error; err != nil {
return err
}
if err := tx.Model(deployment).Updates(map[string]any{
"status": model.PagesDeploymentStatusActive,
"activated_at": &now,
}).Error; err != nil {
return err
}
return tx.Model(project).Updates(map[string]any{
"active_deployment_id": deployment.ID,
}).Error
}); err != nil {
return nil, err
}
return GetPagesProject(project.ID)
}
func DeletePagesDeployment(projectID uint, deploymentID uint) error {
project, err := model.GetPagesProjectByID(projectID)
if err != nil {
return err
}
deployment, err := model.GetPagesDeploymentByID(deploymentID)
if err != nil {
return err
}
if deployment.ProjectID != project.ID {
return errors.New("Pages 部署不属于该项目")
}
if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID == deployment.ID {
return errors.New("不能删除当前激活的 Pages 部署")
}
return model.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("deployment_id = ?", deployment.ID).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
return err
}
if err := tx.Delete(deployment).Error; err != nil {
return err
}
_ = os.Remove(deployment.ArtifactPath)
return nil
})
}
func GetPagesDeploymentPackagePath(deploymentID uint) (string, string, error) {
deployment, err := model.GetPagesDeploymentByID(deploymentID)
if err != nil {
return "", "", err
}
if err = ensurePagesDeploymentInActiveSnapshot(deployment.ID); err != nil {
return "", "", err
}
if strings.TrimSpace(deployment.ArtifactPath) == "" {
return "", "", errors.New("Pages 部署包路径为空")
}
if _, err = os.Stat(deployment.ArtifactPath); err != nil {
return "", "", fmt.Errorf("Pages 部署包不存在: %w", err)
}
return deployment.ArtifactPath, fmt.Sprintf("pages-deployment-%d.zip", deployment.ID), nil
}
func ensurePagesDeploymentInActiveSnapshot(deploymentID uint) error {
version, err := model.GetActiveConfigVersion()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("Pages 部署尚未进入激活配置")
}
return err
}
snapshot, err := parseSnapshotDocument(version.SnapshotJSON)
if err != nil {
return err
}
for _, route := range snapshot.Routes {
if route.UpstreamType != "pages" || route.PagesDeployment == nil {
continue
}
if route.PagesDeployment.DeploymentID == deploymentID {
return nil
}
}
return errors.New("Pages 部署尚未进入激活配置")
}
func buildPagesProject(project *model.PagesProject, input PagesProjectInput) (*model.PagesProject, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, errors.New("Pages 项目名称不能为空")
}
slug := normalizePagesSlug(input.Slug)
if slug == "" {
slug = normalizePagesSlug(name)
}
if !pagesSlugPattern.MatchString(slug) {
return nil, errors.New("Pages 项目标识只能包含小写字母、数字和连字符")
}
if project == nil {
project = &model.PagesProject{}
}
project.Name = name
project.Slug = slug
project.Description = strings.TrimSpace(input.Description)
project.Enabled = input.Enabled
project.SPAFallbackEnabled = input.SPAFallbackEnabled
fallbackPath, err := normalizePagesFallbackPath(input.SPAFallbackPath)
if err != nil {
return nil, err
}
project.SPAFallbackPath = fallbackPath
return project, nil
}
func buildPagesProjectView(project *model.PagesProject) (*PagesProjectView, error) {
if project == nil {
return nil, errors.New("Pages 项目为空")
}
view := &PagesProjectView{
ID: project.ID,
Name: project.Name,
Slug: project.Slug,
Description: project.Description,
Enabled: project.Enabled,
SPAFallbackEnabled: project.SPAFallbackEnabled,
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
ActiveDeploymentID: project.ActiveDeploymentID,
CreatedAt: project.CreatedAt,
UpdatedAt: project.UpdatedAt,
}
if err := model.DB.Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&view.DeploymentCount).Error; err != nil {
return nil, err
}
if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID != 0 {
deployment, err := model.GetPagesDeploymentByID(*project.ActiveDeploymentID)
if err == nil {
view.ActiveDeployment = buildPagesDeploymentView(deployment)
}
}
return view, nil
}
func buildPagesDeploymentView(deployment *model.PagesDeployment) *PagesDeploymentView {
if deployment == nil {
return nil
}
return &PagesDeploymentView{
ID: deployment.ID,
ProjectID: deployment.ProjectID,
DeploymentNumber: deployment.DeploymentNumber,
Checksum: deployment.Checksum,
Status: deployment.Status,
FileCount: deployment.FileCount,
TotalSize: deployment.TotalSize,
EntryFile: deployment.EntryFile,
CreatedBy: deployment.CreatedBy,
CreatedAt: deployment.CreatedAt,
ActivatedAt: deployment.ActivatedAt,
}
}
func normalizePagesSlug(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
var builder strings.Builder
lastDash := false
for _, r := range value {
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if valid {
builder.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
builder.WriteByte('-')
lastDash = true
}
}
return strings.Trim(builder.String(), "-")
}
func normalizePagesFallbackPath(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
value = defaultPagesFallbackPath
}
if len(value) > 512 {
return "", errors.New("SPA fallback 回退路径长度不能超过 512")
}
if !strings.HasPrefix(value, "/") {
return "", errors.New("SPA fallback 回退路径必须以 / 开头")
}
if value == "/" || strings.HasSuffix(value, "/") {
return "", errors.New("SPA fallback 回退路径必须指向具体文件")
}
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
return "", errors.New("SPA fallback 回退路径包含不支持的字符")
}
for _, r := range value {
if r <= 0x20 || r == 0x7f {
return "", errors.New("SPA fallback 回退路径不能包含空白或控制字符")
}
}
for _, segment := range strings.Split(value, "/") {
if segment == "." || segment == ".." {
return "", errors.New("SPA fallback 回退路径不能包含 . 或 .. 路径段")
}
}
cleaned := path.Clean(value)
if cleaned == "." || !strings.HasPrefix(cleaned, "/") {
return "", errors.New("SPA fallback 回退路径不合法")
}
if cleaned == "/" || strings.HasSuffix(cleaned, "/") {
return "", errors.New("SPA fallback 回退路径必须指向具体文件")
}
return cleaned, nil
}
func normalizeStoredPagesFallbackPath(value string) string {
normalized, err := normalizePagesFallbackPath(value)
if err != nil {
return defaultPagesFallbackPath
}
return normalized
}
func normalizePagesEntryFile(raw string) string {
value := path.Clean(strings.TrimSpace(filepath.ToSlash(raw)))
if value == "." || value == "/" {
return defaultPagesEntryFile
}
return strings.TrimPrefix(value, "/")
}
func persistPagesUploadTemp(fileHeader *multipart.FileHeader) (string, string, error) {
file, err := fileHeader.Open()
if err != nil {
return "", "", err
}
defer file.Close()
temp, err := os.CreateTemp("", "openflare-pages-*.zip")
if err != nil {
return "", "", err
}
defer temp.Close()
hash := sha256.New()
limited := io.LimitReader(file, pagesMaxDeploymentBytes+1)
written, err := io.Copy(io.MultiWriter(temp, hash), limited)
if err != nil {
_ = os.Remove(temp.Name())
return "", "", err
}
if written > pagesMaxDeploymentBytes {
_ = os.Remove(temp.Name())
return "", "", fmt.Errorf("Pages 部署包不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024)
}
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), nil
}
func findCommonRootPrefix(files []*zip.File) (string, error) {
var firstFilePath string
hasMultipleFiles := false
for _, item := range files {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
if firstFilePath == "" {
firstFilePath = normalizedPath
} else {
hasMultipleFiles = true
}
}
if firstFilePath == "" {
return "", nil
}
parts := strings.Split(firstFilePath, "/")
if len(parts) <= 1 {
return "", nil
}
commonPrefix := parts[0] + "/"
if hasMultipleFiles {
for _, item := range files {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
if err != nil {
return "", err
}
if skip {
continue
}
if !strings.HasPrefix(normalizedPath, commonPrefix) {
return "", nil
}
}
}
return commonPrefix, nil
}
func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest, error) {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return nil, errors.New("Pages 部署包不是有效 zip 文件")
}
defer reader.Close()
commonPrefix, err := findCommonRootPrefix(reader.File)
if err != nil {
return nil, err
}
manifest := &pagesDeploymentManifest{
Files: []model.PagesDeploymentFile{},
EntryFile: entryFile,
}
entrySeen := false
for _, item := range reader.File {
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
if err != nil {
return nil, err
}
if skip {
continue
}
if commonPrefix != "" {
normalizedPath = strings.TrimPrefix(normalizedPath, commonPrefix)
}
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("Pages 部署包不支持符号链接: %s", normalizedPath)
}
if item.UncompressedSize64 > pagesMaxDeploymentBytes {
return nil, fmt.Errorf("Pages 文件过大: %s", normalizedPath)
}
manifest.FileCount++
if manifest.FileCount > pagesMaxDeploymentFiles {
return nil, fmt.Errorf("Pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles)
}
manifest.TotalSize += int64(item.UncompressedSize64)
if manifest.TotalSize > pagesMaxDeploymentBytes {
return nil, fmt.Errorf("Pages 部署展开后不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024)
}
checksum, err := checksumZipFile(item)
if err != nil {
return nil, err
}
if normalizedPath == entryFile {
entrySeen = true
}
manifest.Files = append(manifest.Files, model.PagesDeploymentFile{
Path: normalizedPath,
Size: int64(item.UncompressedSize64),
Checksum: checksum,
})
}
if manifest.FileCount == 0 {
return nil, errors.New("Pages 部署包不能为空")
}
if !entrySeen {
return nil, fmt.Errorf("Pages 部署包缺少入口文件 %s", entryFile)
}
return manifest, nil
}
func normalizePagesZipPath(raw string) (string, bool, error) {
name := strings.TrimSpace(filepath.ToSlash(raw))
if name == "" {
return "", true, nil
}
if strings.HasSuffix(name, "/") {
return "", true, nil
}
if strings.HasPrefix(name, "/") || path.IsAbs(name) {
return "", false, fmt.Errorf("Pages 部署包不能包含绝对路径: %s", raw)
}
cleaned := path.Clean(name)
if cleaned == "." {
return "", true, nil
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
return "", false, fmt.Errorf("Pages 部署包路径不能逃逸目录: %s", raw)
}
return cleaned, false, nil
}
func checksumZipFile(item *zip.File) (string, error) {
file, err := item.Open()
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err = io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func pagesArtifactPath(projectSlug string, checksum string) (string, error) {
root, err := pagesStorageRoot()
if err != nil {
return "", err
}
return filepath.Join(root, "artifacts", projectSlug, checksum+".zip"), nil
}
func pagesStorageRoot() (string, error) {
if common.SQLDSN != "" {
return filepath.Abs(filepath.Join("data", "pages"))
}
dbPath := strings.TrimSpace(common.SQLitePath)
if dbPath == "" {
return filepath.Abs(filepath.Join("data", "pages"))
}
dir := filepath.Dir(dbPath)
if dir == "." || dir == "" {
dir = "data"
}
return filepath.Abs(filepath.Join(dir, "pages"))
}
func copyFile(src string, dst string) error {
input, err := os.Open(src)
if err != nil {
return err
}
defer input.Close()
output, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer output.Close()
if _, err = io.Copy(output, input); err != nil {
return err
}
return output.Sync()
}
+273
View File
@@ -0,0 +1,273 @@
package service
import (
"archive/zip"
"bytes"
"mime/multipart"
"net/http/httptest"
"openflare/model"
"strings"
"testing"
)
func TestPagesUploadActivateAndPublishStaticRoute(t *testing.T) {
setupServiceTestDB(t)
project, err := CreatePagesProject(PagesProjectInput{
Name: "Marketing Site",
Slug: "marketing-site",
Enabled: true,
SPAFallbackEnabled: true,
SPAFallbackPath: "/app.html",
})
if err != nil {
t.Fatalf("CreatePagesProject failed: %v", err)
}
uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
"index.html": "<h1>Hello Pages</h1>",
"assets/app.js": "console.log('pages')",
"assets/style.css": "body{color:#111}",
}))
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "index.html", "root")
if err != nil {
t.Fatalf("UploadPagesDeployment failed: %v", err)
}
if deployment.FileCount != 3 || deployment.TotalSize == 0 {
t.Fatalf("unexpected deployment manifest: %+v", deployment)
}
project, err = ActivatePagesDeployment(project.ID, deployment.ID)
if err != nil {
t.Fatalf("ActivatePagesDeployment failed: %v", err)
}
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID != deployment.ID {
t.Fatalf("expected active deployment %d, got %+v", deployment.ID, project.ActiveDeploymentID)
}
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pages.example.com",
Enabled: true,
UpstreamType: "pages",
PagesProjectID: &project.ID,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if route.UpstreamType != "pages" || route.PagesProjectID == nil || *route.PagesProjectID != project.ID {
t.Fatalf("expected route to bind Pages project, got %+v", route)
}
result, err := PublishConfigVersion("root", false)
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.SnapshotJSON, `"upstream_type":"pages"`) {
t.Fatalf("expected snapshot to include pages route, got %s", result.Version.SnapshotJSON)
}
if !strings.Contains(result.Version.SnapshotJSON, `"deployment_id":`) {
t.Fatalf("expected snapshot to include pages deployment, got %s", result.Version.SnapshotJSON)
}
if !strings.Contains(result.Version.RenderedConfig, "root \"__OPENFLARE_PAGES_DIR__/deployments/") {
t.Fatalf("expected rendered config to use pages dir placeholder, got:\n%s", result.Version.RenderedConfig)
}
if !strings.Contains(result.Version.SnapshotJSON, `"spa_fallback_path":"/app.html"`) {
t.Fatalf("expected snapshot to include custom SPA fallback path, got %s", result.Version.SnapshotJSON)
}
if !strings.Contains(result.Version.RenderedConfig, "try_files $uri $uri/ /app.html;") {
t.Fatalf("expected SPA fallback try_files, got:\n%s", result.Version.RenderedConfig)
}
if strings.Contains(result.Version.RenderedConfig, "proxy_pass") {
t.Fatalf("Pages route must not render proxy_pass, got:\n%s", result.Version.RenderedConfig)
}
}
func TestPagesProjectRejectsUnsafeFallbackPath(t *testing.T) {
setupServiceTestDB(t)
_, err := CreatePagesProject(PagesProjectInput{
Name: "Unsafe Fallback",
Slug: "unsafe-fallback",
Enabled: true,
SPAFallbackEnabled: true,
SPAFallbackPath: "/index.html; proxy_pass http://evil",
})
if err == nil || !strings.Contains(err.Error(), "回退路径") {
t.Fatalf("expected unsafe SPA fallback path rejection, got %v", err)
}
}
func TestUploadPagesDeploymentRejectsZipSlip(t *testing.T) {
setupServiceTestDB(t)
project, err := CreatePagesProject(PagesProjectInput{
Name: "Unsafe Site",
Slug: "unsafe-site",
Enabled: true,
})
if err != nil {
t.Fatalf("CreatePagesProject failed: %v", err)
}
_, err = UploadPagesDeployment(project.ID, multipartFileHeader(t, "bad.zip", testPagesZip(t, map[string]string{
"../escape.html": "bad",
"index.html": "ok",
})), "index.html", "root")
if err == nil || !strings.Contains(err.Error(), "逃逸目录") {
t.Fatalf("expected zip-slip rejection, got %v", err)
}
}
func TestPagesRouteRequiresActiveDeployment(t *testing.T) {
setupServiceTestDB(t)
project, err := CreatePagesProject(PagesProjectInput{
Name: "Draft Site",
Slug: "draft-site",
Enabled: true,
})
if err != nil {
t.Fatalf("CreatePagesProject failed: %v", err)
}
if _, err = CreateProxyRoute(ProxyRouteInput{
Domain: "draft.example.com",
Enabled: true,
UpstreamType: "pages",
PagesProjectID: &project.ID,
}); err == nil || !strings.Contains(err.Error(), "没有激活部署") {
t.Fatalf("expected active deployment validation, got %v", err)
}
}
func TestPagesDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
setupServiceTestDB(t)
project, err := CreatePagesProject(PagesProjectInput{Name: "Published Site", Slug: "published-site", Enabled: true})
if err != nil {
t.Fatalf("CreatePagesProject failed: %v", err)
}
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
"index.html": "ok",
})), "index.html", "root")
if err != nil {
t.Fatalf("UploadPagesDeployment failed: %v", err)
}
if _, err = ActivatePagesDeployment(project.ID, deployment.ID); err != nil {
t.Fatalf("ActivatePagesDeployment failed: %v", err)
}
if _, _, err = GetPagesDeploymentPackagePath(deployment.ID); err == nil || !strings.Contains(err.Error(), "激活配置") {
t.Fatalf("expected package download to require active config, got %v", err)
}
if _, err = CreateProxyRoute(ProxyRouteInput{
Domain: "published.example.com",
Enabled: true,
UpstreamType: "pages",
PagesProjectID: &project.ID,
}); err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err = PublishConfigVersion("root", false); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
filePath, fileName, err := GetPagesDeploymentPackagePath(deployment.ID)
if err != nil {
t.Fatalf("GetPagesDeploymentPackagePath failed after publish: %v", err)
}
if filePath == "" || fileName == "" {
t.Fatalf("expected package path and file name, got path=%q name=%q", filePath, fileName)
}
}
func testPagesZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, content := range files {
file, err := writer.Create(name)
if err != nil {
t.Fatalf("create zip entry failed: %v", err)
}
if _, err := file.Write([]byte(content)); err != nil {
t.Fatalf("write zip entry failed: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close zip failed: %v", err)
}
return buffer.Bytes()
}
func multipartFileHeader(t *testing.T, fileName string, content []byte) *multipart.FileHeader {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("package", fileName)
if err != nil {
t.Fatalf("CreateFormFile failed: %v", err)
}
if _, err = part.Write(content); err != nil {
t.Fatalf("write multipart file failed: %v", err)
}
if err = writer.Close(); err != nil {
t.Fatalf("close multipart writer failed: %v", err)
}
req := httptest.NewRequest("POST", "/", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err = req.ParseMultipartForm(int64(len(content)) + 1024); err != nil {
t.Fatalf("ParseMultipartForm failed: %v", err)
}
file, header, err := req.FormFile("package")
if err != nil {
t.Fatalf("FormFile failed: %v", err)
}
file.Close()
return header
}
func TestDeletePagesDeploymentRejectsActiveDeployment(t *testing.T) {
setupServiceTestDB(t)
project, err := CreatePagesProject(PagesProjectInput{Name: "Active", Slug: "active", Enabled: true})
if err != nil {
t.Fatalf("CreatePagesProject failed: %v", err)
}
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{"index.html": "ok"})), "index.html", "root")
if err != nil {
t.Fatalf("UploadPagesDeployment failed: %v", err)
}
if _, err = ActivatePagesDeployment(project.ID, deployment.ID); err != nil {
t.Fatalf("ActivatePagesDeployment failed: %v", err)
}
if err = DeletePagesDeployment(project.ID, deployment.ID); err == nil {
t.Fatal("expected active deployment deletion to fail")
}
var stored model.PagesDeployment
if err = model.DB.First(&stored, deployment.ID).Error; err != nil {
t.Fatalf("expected active deployment to remain: %v", err)
}
}
func TestUploadPagesDeploymentWithTopLevelFolder(t *testing.T) {
setupServiceTestDB(t)
project, err := CreatePagesProject(PagesProjectInput{
Name: "Folder Site",
Slug: "folder-site",
Enabled: true,
})
if err != nil {
t.Fatalf("CreatePagesProject failed: %v", err)
}
// Upload a zip with all files inside a top-level directory "Speed-Test-source/"
uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
"Speed-Test-source/index.html": "<h1>Hello Pages</h1>",
"Speed-Test-source/assets/app.js": "console.log('pages')",
}))
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "index.html", "root")
if err != nil {
t.Fatalf("UploadPagesDeployment with folder failed: %v", err)
}
if deployment.FileCount != 2 {
t.Fatalf("expected 2 files, got %d", deployment.FileCount)
}
if deployment.EntryFile != "index.html" {
t.Fatalf("expected EntryFile to be index.html, got %q", deployment.EntryFile)
}
}
+39 -21
View File
@@ -55,8 +55,6 @@ type ProxyRouteInput struct {
CachePolicy string `json:"cache_policy"`
CacheRules []string `json:"cache_rules"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig string `json:"pow_config"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
@@ -66,6 +64,7 @@ type ProxyRouteInput struct {
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
PagesProjectID *uint `json:"pages_project_id"`
}
type ProxyRouteView struct {
@@ -95,8 +94,6 @@ type ProxyRouteView struct {
CacheRuleList []string `json:"cache_rule_list"`
CustomHeaders string `json:"custom_headers"`
CustomHeaderList []ProxyRouteCustomHeaderInput `json:"custom_header_list"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
@@ -106,6 +103,7 @@ type ProxyRouteView struct {
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
PagesProjectID *uint `json:"pages_project_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -183,6 +181,13 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
// Tunnel type: origin URL is auto-filled during config rendering
originURL = "http://127.0.0.1"
upstreams = []string{originURL}
} else if upstreamType == "pages" {
if err := validatePagesRouteInput(input.PagesProjectID); err != nil {
return nil, err
}
// Keep persisted upstreams HTTP-compatible; Pages rendering uses pages_project_id.
originURL = "http://127.0.0.1"
upstreams = []string{originURL}
} else {
originURL, originID, err = resolveProxyRoutePrimaryOrigin(input)
if err != nil {
@@ -230,15 +235,6 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
return nil, err
}
powConfig, err := normalizePoWConfig(input.PoWEnabled, input.PoWConfig)
if err != nil {
return nil, err
}
powConfigJSON, err := json.Marshal(powConfig)
if err != nil {
return nil, err
}
if !input.EnableHTTPS {
input.RedirectHTTP = false
input.CertID = nil
@@ -321,8 +317,6 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy)
route.CacheRules = string(cacheRulesJSON)
route.CustomHeaders = string(customHeadersJSON)
route.PoWEnabled = input.PoWEnabled
route.PoWConfig = string(powConfigJSON)
route.BasicAuthEnabled = input.BasicAuthEnabled
route.BasicAuthUsername = input.BasicAuthUsername
route.BasicAuthPassword = input.BasicAuthPassword
@@ -339,10 +333,17 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
route.TunnelNodeID = tunnelNodeID
route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr)
route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol)
route.PagesProjectID = nil
} else if upstreamType == "pages" {
route.TunnelNodeID = nil
route.TunnelTargetAddr = ""
route.TunnelTargetProtocol = ""
route.PagesProjectID = input.PagesProjectID
} else {
route.TunnelNodeID = nil
route.TunnelTargetAddr = ""
route.TunnelTargetProtocol = ""
route.PagesProjectID = nil
}
return route, nil
}
@@ -379,10 +380,6 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
if err != nil {
return nil, err
}
powConfig, err := decodeStoredPoWConfig(route.PoWEnabled, route.PoWConfig)
if err != nil {
return nil, err
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return nil, err
@@ -423,8 +420,6 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
CacheRuleList: cacheRules,
CustomHeaders: route.CustomHeaders,
CustomHeaderList: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
BasicAuthEnabled: route.BasicAuthEnabled,
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
@@ -434,6 +429,7 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
TunnelID: route.TunnelNodeID,
TunnelTargetAddr: route.TunnelTargetAddr,
TunnelTargetProtocol: route.TunnelTargetProtocol,
PagesProjectID: route.PagesProjectID,
CreatedAt: route.CreatedAt,
UpdatedAt: route.UpdatedAt,
}, nil
@@ -474,6 +470,26 @@ func validateTunnelRouteInput(tunnelNodeID *uint, targetAddr string, targetProto
}
}
func validatePagesRouteInput(projectID *uint) error {
if projectID == nil || *projectID == 0 {
return errors.New("pages_project_id is required for Pages upstream")
}
project, err := model.GetPagesProjectByID(*projectID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("Pages 项目不存在")
}
return err
}
if !project.Enabled {
return errors.New("Pages 项目未启用")
}
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
return errors.New("Pages 项目没有激活部署")
}
return nil
}
func normalizeProxyRouteSiteNameInput(route *model.ProxyRoute, raw string, primaryDomain string) string {
siteName := strings.TrimSpace(raw)
if siteName != "" {
@@ -1291,6 +1307,8 @@ func normalizeUpstreamType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "tunnel":
return "tunnel"
case "pages":
return "pages"
default:
return "direct"
}
+304
View File
@@ -0,0 +1,304 @@
package service
import (
"fmt"
"log/slog"
"openflare/common"
"openflare/model"
"openflare/utils/uptimekuma"
"strings"
"sync/atomic"
"time"
)
var isSyncing atomic.Bool
func SyncToUptimeKuma() error {
if !common.UptimeKumaEnabled {
return fmt.Errorf("Uptime Kuma integration is disabled")
}
if !isSyncing.CompareAndSwap(false, true) {
return fmt.Errorf("sync task is already in progress, please try again later")
}
defer isSyncing.Store(false)
kumaUrl := strings.TrimSpace(common.UptimeKumaUrl)
kumaUsername := strings.TrimSpace(common.UptimeKumaUsername)
kumaPassword := strings.TrimSpace(common.UptimeKumaPassword)
if kumaUrl == "" || kumaUsername == "" || kumaPassword == "" {
return fmt.Errorf("Uptime Kuma URL, username, or password is not configured (URL: %q, Username: %q, PasswordLength: %d)", kumaUrl, kumaUsername, len(kumaPassword))
}
slog.Info("Starting Uptime Kuma sync process", "url", kumaUrl, "username", kumaUsername, "scope", common.UptimeKumaMonitorScope)
// 1. Fetch expected sites
allRoutes, err := model.ListProxyRoutes()
if err != nil {
return fmt.Errorf("failed to list local proxy routes: %w", err)
}
var expectedRoutes []*model.ProxyRoute
scope := common.UptimeKumaMonitorScope
if scope == "selected" {
selectedList := strings.Split(common.UptimeKumaSelectedSites, ",")
selectedMap := make(map[string]bool)
for _, name := range selectedList {
trimmedName := strings.TrimSpace(name)
if trimmedName != "" {
selectedMap[trimmedName] = true
}
}
for _, route := range allRoutes {
if route.Enabled && selectedMap[route.SiteName] {
expectedRoutes = append(expectedRoutes, route)
}
}
} else {
for _, route := range allRoutes {
if route.Enabled {
expectedRoutes = append(expectedRoutes, route)
}
}
}
// 2. Connect to Uptime Kuma
slog.Debug("Connecting to Uptime Kuma socket endpoint", "url", kumaUrl)
client := uptimekuma.NewSocketIOClient(kumaUrl)
if err := client.Connect(); err != nil {
slog.Error("Failed to connect to Uptime Kuma endpoint", "url", kumaUrl, "error", err)
return fmt.Errorf("failed to connect to Uptime Kuma: %w", err)
}
defer client.Close()
// 3. Login
slog.Debug("Sending login request to Uptime Kuma", "username", kumaUsername)
var loginAck string
loginPayload := map[string]string{
"username": kumaUsername,
"password": kumaPassword,
}
loginAck, err = client.Emit("login", loginPayload)
if err != nil {
slog.Error("Failed to send login request to Uptime Kuma", "username", kumaUsername, "error", err)
return fmt.Errorf("login request failed: %w", err)
}
var loginResult struct {
Ok bool `json:"ok"`
}
if err := uptimekuma.ParseAckResponse(loginAck, &loginResult); err != nil || !loginResult.Ok {
slog.Error("Uptime Kuma login verification failed", "username", kumaUsername, "error", err)
return fmt.Errorf("login failed: %w", err)
}
slog.Debug("Successfully logged into Uptime Kuma", "username", kumaUsername)
// 4. Wait for monitor list event
slog.Debug("Waiting for monitor list push from Uptime Kuma")
select {
case <-client.GetMonitorListChan():
slog.Debug("Received monitor list from Uptime Kuma")
case <-time.After(5 * time.Second):
slog.Error("Timeout waiting for Uptime Kuma monitorList push event")
return fmt.Errorf("timeout waiting for monitorList event from Uptime Kuma")
}
// 5. Get existing tags to find "OpenFlare"
slog.Debug("Fetching tags from Uptime Kuma")
tagsAck, err := client.Emit("getTags")
if err != nil {
slog.Error("Failed to request tags from Uptime Kuma", "error", err)
return fmt.Errorf("failed to fetch tags: %w", err)
}
var tagsResult struct {
Ok bool `json:"ok"`
Tags []uptimekuma.UptimeKumaTagItem `json:"tags"`
}
if err := uptimekuma.ParseAckResponse(tagsAck, &tagsResult); err != nil {
slog.Error("Failed to parse tags response from Uptime Kuma", "error", err)
return fmt.Errorf("parse tags response failed: %w", err)
}
var openFlareTagID int
for _, t := range tagsResult.Tags {
if t.Name == "OpenFlare" {
openFlareTagID = t.ID
break
}
}
// Create "OpenFlare" tag if not exists
if openFlareTagID == 0 {
slog.Debug("OpenFlare tag not found, creating new tag")
addTagAck, err := client.Emit("addTag", map[string]string{
"name": "OpenFlare",
"color": "#4f46e5",
})
if err != nil {
slog.Error("Failed to create OpenFlare tag in Uptime Kuma", "error", err)
return fmt.Errorf("failed to create tag: %w", err)
}
var tagResult struct {
Ok bool `json:"ok"`
Tag struct {
ID int `json:"id"`
} `json:"tag"`
}
if err := uptimekuma.ParseAckResponse(addTagAck, &tagResult); err != nil || tagResult.Tag.ID == 0 {
slog.Error("Failed to parse addTag response from Uptime Kuma", "error", err)
return fmt.Errorf("parse addTag response failed: %w", err)
}
openFlareTagID = tagResult.Tag.ID
slog.Debug("Successfully created OpenFlare tag", "tag_id", openFlareTagID)
} else {
slog.Debug("Found existing OpenFlare tag", "tag_id", openFlareTagID)
}
// 6. Filter existing monitors by "OpenFlare" tag
existingOpenFlareMonitors := make(map[string]uptimekuma.UptimeKumaMonitor)
monitors := client.GetMonitorList()
for _, m := range monitors {
hasOpenFlareTag := false
for _, tag := range m.Tags {
if tag.Name == "OpenFlare" || tag.ID == openFlareTagID {
hasOpenFlareTag = true
break
}
}
if hasOpenFlareTag {
existingOpenFlareMonitors[m.Name] = m
}
}
// Helper to format route URL
getRouteURL := func(route *model.ProxyRoute) string {
domains, err := decodeStoredDomains(route.Domains, route.Domain)
domain := route.Domain
if err == nil && len(domains) > 0 {
domain = domains[0]
}
if route.EnableHTTPS {
return "https://" + domain
}
return "http://" + domain
}
expectedSitesMap := make(map[string]bool)
// 7. Sync Loop
for _, route := range expectedRoutes {
expectedSitesMap[route.SiteName] = true
targetURL := getRouteURL(route)
existing, exists := existingOpenFlareMonitors[route.SiteName]
if !exists {
// Create monitor
slog.Info("Creating monitor in Uptime Kuma", "name", route.SiteName, "url", targetURL)
monitorPayload := map[string]any{
"type": "http",
"name": route.SiteName,
"url": targetURL,
"interval": common.UptimeKumaInterval,
"maxretries": common.UptimeKumaRetry,
"retryInterval": common.UptimeKumaRetryInterval,
"timeout": common.UptimeKumaTimeout,
"active": true,
"resendInterval": 0,
"expiryNotification": false,
"ignoreTls": false,
"accepted_statuscodes": []string{"200-299"},
"dns_resolve_type": "A",
"conditions": []any{},
}
addAck, err := client.Emit("add", monitorPayload)
if err != nil {
slog.Error("Failed to add monitor to Uptime Kuma", "name", route.SiteName, "error", err)
continue
}
var addResult struct {
Ok bool `json:"ok"`
MonitorID int `json:"monitorID"`
}
if err := uptimekuma.ParseAckResponse(addAck, &addResult); err != nil || addResult.MonitorID == 0 {
slog.Error("Failed to parse add monitor result", "name", route.SiteName, "error", err)
continue
}
// Add tag
slog.Debug("Adding OpenFlare tag to the new monitor", "name", route.SiteName, "monitor_id", addResult.MonitorID, "tag_id", openFlareTagID)
tagAck, err := client.Emit("addMonitorTag", openFlareTagID, addResult.MonitorID, "")
if err != nil {
slog.Error("Failed to add tag to monitor in Uptime Kuma", "name", route.SiteName, "monitorID", addResult.MonitorID, "error", err)
} else {
if err := uptimekuma.ParseAckResponse(tagAck, nil); err != nil {
slog.Error("Failed to parse add tag result", "name", route.SiteName, "monitorID", addResult.MonitorID, "error", err)
} else {
slog.Debug("OpenFlare tag successfully added to monitor", "name", route.SiteName, "monitor_id", addResult.MonitorID)
}
}
} else {
// Check if updates are needed
needsUpdate := existing.Url != targetURL ||
existing.Interval != common.UptimeKumaInterval ||
existing.MaxRetries != common.UptimeKumaRetry ||
existing.RetryInterval != common.UptimeKumaRetryInterval ||
existing.Timeout != common.UptimeKumaTimeout
if needsUpdate {
slog.Info("Updating monitor in Uptime Kuma due to settings mismatch",
"name", route.SiteName,
"url_changed", existing.Url != targetURL,
"interval_changed", existing.Interval != common.UptimeKumaInterval,
"max_retries_changed", existing.MaxRetries != common.UptimeKumaRetry,
"retry_interval_changed", existing.RetryInterval != common.UptimeKumaRetryInterval,
"timeout_changed", existing.Timeout != common.UptimeKumaTimeout,
)
monitorPayload := map[string]any{
"id": existing.ID,
"type": "http",
"name": route.SiteName,
"url": targetURL,
"interval": common.UptimeKumaInterval,
"maxretries": common.UptimeKumaRetry,
"retryInterval": common.UptimeKumaRetryInterval,
"timeout": common.UptimeKumaTimeout,
"active": true,
"resendInterval": 0,
"expiryNotification": false,
"ignoreTls": false,
"accepted_statuscodes": []string{"200-299"},
"dns_resolve_type": "A",
"conditions": []any{},
}
editAck, err := client.Emit("editMonitor", monitorPayload)
if err != nil {
slog.Error("Failed to edit monitor in Uptime Kuma", "name", route.SiteName, "error", err)
} else {
if err := uptimekuma.ParseAckResponse(editAck, nil); err != nil {
slog.Error("Failed to parse edit monitor result", "name", route.SiteName, "error", err)
} else {
slog.Info("Successfully updated monitor in Uptime Kuma", "name", route.SiteName)
}
}
}
}
}
// 8. Delete Loop
for name, m := range existingOpenFlareMonitors {
if !expectedSitesMap[name] {
slog.Info("Deleting monitor in Uptime Kuma", "name", name, "monitorID", m.ID)
deleteAck, err := client.Emit("deleteMonitor", m.ID)
if err != nil {
slog.Error("Failed to delete monitor in Uptime Kuma", "name", name, "monitorID", m.ID, "error", err)
} else {
if err := uptimekuma.ParseAckResponse(deleteAck, nil); err != nil {
slog.Error("Failed to parse delete monitor result", "name", name, "monitorID", m.ID, "error", err)
}
}
}
}
return nil
}
+156 -30
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"net/url"
"path"
"regexp"
"sort"
"strings"
@@ -33,12 +34,7 @@ func Render(doc Document, certificateFiles []SupportFile) (*Result, error) {
if err != nil {
return nil, err
}
powConfig, err := RenderPoWConfig(doc)
if err != nil {
return nil, err
}
files := append([]SupportFile(nil), certificateFiles...)
files = append(files, SupportFile{Path: "pow_config.json", Content: powConfig})
files = append(files, SupportFile{Path: "waf_config.json", Content: wafConfig})
files = DedupeSupportFiles(files)
return &Result{
@@ -84,6 +80,63 @@ func RenderRouteConfig(doc Document, certificateFiles []SupportFile) (string, er
if displayName == "" {
displayName = domains[0]
}
cacheConfig := routeCacheConfig{Enabled: route.CacheEnabled, Policy: route.CachePolicy, Rules: route.CacheRules}
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
powEnabled, _ := getPoWConfigForRoute(route.ID, doc.WAF)
if normalizeRouteUpstreamType(route.UpstreamType) == "pages" {
if route.PagesDeployment == nil {
return "", fmt.Errorf("route %s pages deployment is missing", route.Domain)
}
if !route.EnableHTTPS {
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
continue
}
certIDs := normalizeCertIDs(route.CertID, route.CertIDs)
domainCertIDs := normalizeDomainCertIDs(domains, certIDs, route.DomainCertIDs)
if len(certIDs) == 0 {
return "", fmt.Errorf("路由 %s 未配置证书", route.Domain)
}
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
}
certPEM, ok := certificates[certID]
if !ok {
return "", fmt.Errorf("route %s certificate %d does not exist", route.Domain, certID)
}
if err := validateCertificateCoverage(certPEM, assignedDomains); err != nil {
return "", fmt.Errorf("site %s certificate validation failed: %w", displayName, err)
}
}
if route.RedirectHTTP {
if len(httpOnlyDomains) > 0 {
builder.WriteString(renderHTTPPagesServer(renderServerNames(httpOnlyDomains), displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
}
for _, certID := range certIDs {
if assignedDomains := domainsByCertID[certID]; len(assignedDomains) > 0 {
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains)))
}
}
} else {
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
}
for _, certID := range certIDs {
if assignedDomains := domainsByCertID[certID]; len(assignedDomains) > 0 {
builder.WriteString(renderHTTPSPagesServer(renderServerNames(assignedDomains), displayName, certID, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig))
}
}
continue
}
upstreams := route.Upstreams
if len(upstreams) == 0 && strings.TrimSpace(route.OriginURL) != "" {
upstreams = []string{route.OriginURL}
@@ -92,12 +145,6 @@ func RenderRouteConfig(doc Document, certificateFiles []SupportFile) (string, er
if upstreamConfig.UsesNamedUpstream {
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
}
cacheConfig := routeCacheConfig{Enabled: route.CacheEnabled, Policy: route.CachePolicy, Rules: route.CacheRules}
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
powEnabled, _ := getPoWConfigForRoute(route.ID, doc.WAF)
if route.PoWEnabled {
powEnabled = true
}
if !route.EnableHTTPS {
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig))
continue
@@ -159,12 +206,6 @@ func RenderPoWConfig(doc Document) (string, error) {
entries := make([]domainEntry, 0)
for _, route := range doc.Routes {
powEnabled, powConfig := getPoWConfigForRoute(route.ID, doc.WAF)
if route.PoWEnabled {
powEnabled = true
if route.PoWConfig != nil {
powConfig = route.PoWConfig
}
}
if !powEnabled {
continue
}
@@ -179,19 +220,21 @@ func RenderPoWConfig(doc Document) (string, error) {
func RenderWAFConfig(snapshot WAFDocument) (string, error) {
type wafRuntimeRuleGroup struct {
ID uint `json:"id"`
Name string `json:"name"`
IsGlobal bool `json:"is_global"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
ID uint `json:"id"`
Name string `json:"name"`
IsGlobal bool `json:"is_global"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
}
type wafRuntimeConfig struct {
DefaultBlockStatusCode int `json:"default_block_status_code"`
@@ -213,6 +256,10 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
globalGroupIDs = append(globalGroupIDs, group.ID)
}
enabledGroupIDs[group.ID] = struct{}{}
powConfig := group.PoWConfig
if !group.PoWEnabled {
powConfig = nil
}
groups = append(groups, wafRuntimeRuleGroup{
ID: group.ID,
Name: group.Name,
@@ -227,6 +274,8 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
CountryBlacklist: group.CountryBlacklist,
RegionWhitelist: group.RegionWhitelist,
RegionBlacklist: group.RegionBlacklist,
PoWEnabled: group.PoWEnabled,
PoWConfig: powConfig,
})
}
sort.Slice(groups, func(i, j int) bool {
@@ -372,6 +421,10 @@ func renderHTTPProxyServer(serverNames string, siteName string, originURL string
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
}
func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s root %s;\n index %s;\n\n location / {\n%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
}
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)
}
@@ -388,6 +441,66 @@ func renderHTTPSServer(serverNames string, siteName string, originURL string, or
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
}
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID)
keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID)
var h3Listen string
var h3Header string
if cfg.HTTP3Enabled {
h3Listen = " listen 443 quic;\n"
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
}
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
}
func renderPagesLocationBlock(deployment *PagesDeployment, limitConfig routeLimitConfig) string {
var builder strings.Builder
builder.WriteString(renderRouteLimitBlock(limitConfig))
if deployment != nil && deployment.SPAFallbackEnabled {
builder.WriteString(fmt.Sprintf(" try_files $uri $uri/ %s;\n", pagesFallbackPath(deployment)))
} else {
builder.WriteString(" try_files $uri $uri/ =404;\n")
}
return builder.String()
}
func pagesDeploymentRoot(deployment *PagesDeployment) string {
if deployment == nil || strings.TrimSpace(deployment.LocalRoot) == "" {
return PagesDirPlaceholder
}
return filepathToNginxPath(deployment.LocalRoot)
}
func pagesEntryFile(deployment *PagesDeployment) string {
if deployment == nil || strings.TrimSpace(deployment.EntryFile) == "" {
return "index.html"
}
return strings.TrimPrefix(filepathToNginxPath(deployment.EntryFile), "/")
}
func pagesFallbackPath(deployment *PagesDeployment) string {
if deployment == nil || strings.TrimSpace(deployment.SPAFallbackPath) == "" {
return "/index.html"
}
value := filepathToNginxPath(strings.TrimSpace(deployment.SPAFallbackPath))
if !strings.HasPrefix(value, "/") {
value = "/" + value
}
if value == "/" || strings.HasSuffix(value, "/") || strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") || strings.ContainsAny(value, " \t\r\n") {
return "/index.html"
}
for _, segment := range strings.Split(value, "/") {
if segment == "." || segment == ".." {
return "/index.html"
}
}
cleaned := path.Clean(value)
if cleaned == "/" || strings.HasSuffix(cleaned, "/") {
return "/index.html"
}
return cleaned
}
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []CustomHeader, upstreamConfig routeUpstreamConfig, cfg ConfigSnapshot) string {
var builder strings.Builder
if strings.TrimSpace(originHost) != "" {
@@ -543,6 +656,15 @@ func buildRouteUpstreamConfig(route Route, upstreams []string) routeUpstreamConf
return routeUpstreamConfig{Name: buildRouteUpstreamName(route), Scheme: scheme, Servers: servers, UsesNamedUpstream: true}
}
func normalizeRouteUpstreamType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "pages":
return "pages"
default:
return "direct"
}
}
func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("upstream %s {\n", upstreamConfig.Name))
@@ -787,6 +909,10 @@ func quoteNginxStringLiteral(value string) string {
return fmt.Sprintf(`"%s"`, escaped)
}
func filepathToNginxPath(value string) string {
return strings.ReplaceAll(strings.TrimSpace(value), `\`, `/`)
}
func escapeNginxString(value string) string {
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
@@ -9,6 +9,7 @@ const (
ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__"
SourceConfigFileName = "openresty_config.json"
)
@@ -90,32 +91,46 @@ type PoWConfig struct {
}
type Route struct {
ID uint `json:"id,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 []CustomHeader `json:"custom_headers,omitempty"`
PoWEnabled bool `json:"pow_enabled,omitempty"`
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
Remark string `json:"remark,omitempty"`
ID uint `json:"id,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 []CustomHeader `json:"custom_headers,omitempty"`
PoWEnabled bool `json:"pow_enabled,omitempty"`
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
Remark string `json:"remark,omitempty"`
UpstreamType string `json:"upstream_type,omitempty"`
PagesDeployment *PagesDeployment `json:"pages_deployment,omitempty"`
}
type PagesDeployment struct {
ProjectID uint `json:"project_id"`
ProjectSlug string `json:"project_slug"`
DeploymentID uint `json:"deployment_id"`
DeploymentNumber int `json:"deployment_number"`
Checksum string `json:"checksum"`
EntryFile string `json:"entry_file"`
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
SPAFallbackPath string `json:"spa_fallback_path"`
LocalRoot string `json:"local_root"`
}
type WAFRuleGroup struct {
+373
View File
@@ -0,0 +1,373 @@
package uptimekuma
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type UptimeKumaMonitor struct {
ID int `json:"id"`
Name string `json:"name"`
Url string `json:"url"`
Type string `json:"type"`
Interval int `json:"interval"`
MaxRetries int `json:"maxretries"`
RetryInterval int `json:"retryInterval"`
Timeout int `json:"timeout"`
Tags []UptimeKumaTag `json:"tags"`
}
type UptimeKumaTag struct {
ID int `json:"tag_id"`
Name string `json:"name"`
Color string `json:"color"`
}
type UptimeKumaTagItem struct {
ID int `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
type SocketIOClient struct {
baseURL string
httpClient *http.Client
sid string
ackMutex sync.Mutex
ackID int
ackChanMap map[int]chan string
doneChan chan struct{}
closeOnce sync.Once
monitorListMutex sync.RWMutex
monitorList map[string]UptimeKumaMonitor
monitorListChan chan struct{}
monitorListOnce sync.Once
ctx context.Context
cancel context.CancelFunc
err error
}
func NewSocketIOClient(baseURL string) *SocketIOClient {
ctx, cancel := context.WithCancel(context.Background())
return &SocketIOClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
ackChanMap: make(map[int]chan string),
doneChan: make(chan struct{}),
monitorListChan: make(chan struct{}),
monitorList: make(map[string]UptimeKumaMonitor),
ctx: ctx,
cancel: cancel,
}
}
func (c *SocketIOClient) Connect() error {
slog.Debug("Uptime Kuma client starting handshake", "baseURL", c.baseURL)
// 1. Handshake
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling", c.baseURL)
reqHandshake, err := http.NewRequestWithContext(c.ctx, "GET", u, nil)
if err != nil {
return fmt.Errorf("create handshake request failed: %w", err)
}
resp, err := c.httpClient.Do(reqHandshake)
if err != nil {
slog.Error("Uptime Kuma handshake connection failed", "url", u, "error", err)
return fmt.Errorf("handshake request failed: %w", err)
}
defer resp.Body.Close()
bs, err := io.ReadAll(resp.Body)
if err != nil {
slog.Error("Failed to read Uptime Kuma handshake response body", "error", err)
return fmt.Errorf("read handshake body failed: %w", err)
}
bodyStr := string(bs)
slog.Debug("Received handshake response from Uptime Kuma", "body", bodyStr)
if len(bodyStr) == 0 || bodyStr[0] != '0' {
return fmt.Errorf("invalid handshake response format: %s", bodyStr)
}
var hs struct {
Sid string `json:"sid"`
}
if err := json.Unmarshal([]byte(bodyStr[1:]), &hs); err != nil {
return fmt.Errorf("unmarshal handshake sid failed: %w", err)
}
c.sid = hs.Sid
slog.Debug("Uptime Kuma handshake success", "sid", c.sid)
// 2. Namespace Connect
slog.Debug("Sending namespace connect request to Uptime Kuma", "sid", c.sid)
connectURL := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
req, err := http.NewRequestWithContext(c.ctx, "POST", connectURL, strings.NewReader("40"))
if err != nil {
return fmt.Errorf("create connect request failed: %w", err)
}
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
respConnect, err := c.httpClient.Do(req)
if err != nil {
slog.Error("Uptime Kuma namespace connect request failed", "sid", c.sid, "error", err)
return fmt.Errorf("namespace connect failed: %w", err)
}
respConnect.Body.Close()
slog.Debug("Namespace connected successfully to Uptime Kuma", "sid", c.sid)
// 3. Start Polling Loop
go c.pollLoop()
return nil
}
func (c *SocketIOClient) pollLoop() {
slog.Debug("Uptime Kuma polling loop started", "sid", c.sid)
defer c.Close()
for {
select {
case <-c.doneChan:
slog.Debug("Uptime Kuma polling loop stopped (doneChan closed)", "sid", c.sid)
return
default:
}
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
reqPoll, err := http.NewRequestWithContext(c.ctx, "GET", u, nil)
if err != nil {
slog.Error("Failed to create Uptime Kuma polling request", "sid", c.sid, "error", err)
c.err = err
return
}
resp, err := c.httpClient.Do(reqPoll)
if err != nil {
slog.Error("Uptime Kuma polling request failed", "sid", c.sid, "error", err)
c.err = err
return
}
bs, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
slog.Error("Failed to read Uptime Kuma polling body", "sid", c.sid, "error", err)
c.err = err
return
}
bodyStr := string(bs)
if len(bodyStr) == 0 {
continue
}
slog.Debug("Received polling payload from Uptime Kuma", "length", len(bodyStr))
packets := strings.Split(bodyStr, "\x1e")
for _, pkt := range packets {
if len(pkt) == 0 {
continue
}
engineIOType := pkt[0]
payload := pkt[1:]
slog.Debug("Parsing engine.io packet", "type", string(engineIOType), "payload_len", len(payload))
switch engineIOType {
case '2': // Ping
slog.Debug("Received engine.io ping, responding with pong", "sid", c.sid)
c.sendPong()
case '4': // Message
if len(payload) == 0 {
continue
}
socketIOType := payload[0]
socketIOPayload := payload[1:]
slog.Debug("Parsing socket.io packet", "type", string(socketIOType), "payload", socketIOPayload)
switch socketIOType {
case '2': // Event
c.handleEvent(socketIOPayload)
case '3': // Ack
c.handleAck(socketIOPayload)
}
}
}
}
}
func (c *SocketIOClient) sendPong() {
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
req, err := http.NewRequestWithContext(c.ctx, "POST", u, strings.NewReader("3"))
if err != nil {
return
}
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
resp, err := c.httpClient.Do(req)
if err == nil {
resp.Body.Close()
}
}
func (c *SocketIOClient) handleEvent(payload string) {
var arr []json.RawMessage
if err := json.Unmarshal([]byte(payload), &arr); err != nil || len(arr) < 2 {
return
}
var eventName string
if err := json.Unmarshal(arr[0], &eventName); err != nil {
return
}
if eventName == "monitorList" {
var list map[string]UptimeKumaMonitor
if err := json.Unmarshal(arr[1], &list); err == nil {
c.monitorListMutex.Lock()
c.monitorList = list
c.monitorListMutex.Unlock()
c.monitorListOnce.Do(func() {
close(c.monitorListChan)
})
}
}
}
func (c *SocketIOClient) handleAck(payload string) {
idx := strings.IndexByte(payload, '[')
if idx == -1 {
return
}
ackIDStr := payload[:idx]
ackID, err := strconv.Atoi(ackIDStr)
if err != nil {
return
}
c.ackMutex.Lock()
ch, ok := c.ackChanMap[ackID]
if ok {
delete(c.ackChanMap, ackID)
c.ackMutex.Unlock()
select {
case ch <- payload[idx:]:
default:
}
} else {
c.ackMutex.Unlock()
}
}
func (c *SocketIOClient) Emit(event string, args ...any) (string, error) {
c.ackMutex.Lock()
id := c.ackID
c.ackID++
ch := make(chan string, 1)
c.ackChanMap[id] = ch
c.ackMutex.Unlock()
payloadArr := []any{event}
payloadArr = append(payloadArr, args...)
bs, err := json.Marshal(payloadArr)
if err != nil {
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
slog.Error("Failed to marshal event payload", "event", event, "error", err)
return "", err
}
body := fmt.Sprintf("42%d%s", id, string(bs))
slog.Debug("Emitting Socket.IO event", "event", event, "ackID", id, "payload", string(bs))
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
req, err := http.NewRequestWithContext(c.ctx, "POST", u, strings.NewReader(body))
if err != nil {
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
return "", err
}
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
resp, err := c.httpClient.Do(req)
if err != nil {
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
slog.Error("Failed to send Emit request", "event", event, "ackID", id, "error", err)
return "", err
}
resp.Body.Close()
select {
case result := <-ch:
slog.Debug("Received Ack for event", "event", event, "ackID", id, "response", result)
return result, nil
case <-time.After(10 * time.Second):
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
slog.Error("Timeout waiting for event Ack", "event", event, "ackID", id)
return "", fmt.Errorf("timeout waiting for ack for event: %s", event)
case <-c.doneChan:
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
slog.Error("Client closed while waiting for event Ack", "event", event, "ackID", id)
return "", fmt.Errorf("client closed while waiting for event ack: %s", event)
}
}
func (c *SocketIOClient) Close() {
c.closeOnce.Do(func() {
c.cancel()
close(c.doneChan)
})
}
func (c *SocketIOClient) GetMonitorListChan() <-chan struct{} {
return c.monitorListChan
}
func (c *SocketIOClient) GetMonitorList() map[string]UptimeKumaMonitor {
c.monitorListMutex.RLock()
defer c.monitorListMutex.RUnlock()
// Return a copy to prevent concurrent map read/write access
m := make(map[string]UptimeKumaMonitor, len(c.monitorList))
for k, v := range c.monitorList {
m[k] = v
}
return m
}
func ParseAckResponse(response string, target any) error {
var arr []json.RawMessage
if err := json.Unmarshal([]byte(response), &arr); err != nil || len(arr) == 0 {
return fmt.Errorf("invalid ack response format: %s", response)
}
var status struct {
Ok bool `json:"ok"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(arr[0], &status); err == nil {
if !status.Ok {
errMsg := status.Msg
if errMsg == "" {
errMsg = "unknown error from Uptime Kuma"
}
return fmt.Errorf("Uptime Kuma error response: %s", errMsg)
}
}
if target != nil {
return json.Unmarshal(arr[0], target)
}
return nil
}
@@ -0,0 +1,11 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { PagesProjectDetailPage } from '@/features/pages/components/pages-page';
export default function PagesProjectDetailRoute() {
const searchParams = useSearchParams();
return <PagesProjectDetailPage projectId={searchParams.get('id') ?? ''} />;
}
@@ -0,0 +1,5 @@
import { PagesPage } from '@/features/pages/components/pages-page';
export default function Page() {
return <PagesPage />;
}
+11
View File
@@ -176,3 +176,14 @@ button {
@apply font-sans;
}
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
@@ -80,6 +80,15 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
<path d="m14 15 3 2-3 2" />
</svg>
);
case 'pages':
return (
<svg {...commonProps}>
<path d="M5 5.5h14v13H5z" />
<path d="M8 9h8" />
<path d="M8 12h5" />
<path d="M8 15h7" />
</svg>
);
case 'waf':
return <ShieldCheck className="h-[18px] w-[18px]" strokeWidth={1.8} />;
case 'release':
@@ -208,24 +217,21 @@ function SidebarContent({
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
OpenFlare
</p>
<p className="text-xs text-[var(--foreground-secondary)]">控制面</p>
</div>
) : null}
</div>
<nav className="flex-1 space-y-2">
<div className="flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1">
{dashboardNavigation.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
/>
))}
</div>
<nav className="min-h-0 flex-1 overflow-y-auto no-scrollbar pr-1 flex flex-col gap-2">
{dashboardNavigation.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
/>
))}
</nav>
</div>
);
@@ -60,7 +60,6 @@ import {
HealthEventFilter,
NodeDetailTab,
MetricBar,
SummaryStat,
} from './node-shared';
export function RelayDetailPage({ node }: { node: NodeItem }) {
@@ -318,7 +317,6 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
: stableAgentReleaseQuery.isFetching;
const applyLogs = applyLogsQuery.data?.rows ?? [];
const latestHealthEvent = activeHealthEvents[0] ?? null;
const memoryUsageRatio = formatUsageRatio(
latestMetricSnapshot?.memory_used_bytes,
latestMetricSnapshot?.memory_total_bytes,
@@ -0,0 +1,116 @@
import { apiRequest, getApiUrl, ApiError } from '@/lib/api/client';
import type { ApiEnvelope } from '@/types/api';
import type {
PagesDeployment,
PagesProject,
PagesProjectPayload,
} from '@/features/pages/types';
export function getPagesProjects() {
return apiRequest<PagesProject[]>('/pages/');
}
export function getPagesProject(id: number) {
return apiRequest<PagesProject>(`/pages/${id}`);
}
export function createPagesProject(payload: PagesProjectPayload) {
return apiRequest<PagesProject>('/pages/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updatePagesProject(id: number, payload: PagesProjectPayload) {
return apiRequest<PagesProject>(`/pages/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deletePagesProject(id: number) {
return apiRequest<void>(`/pages/${id}/delete`, {
method: 'POST',
});
}
export function getPagesDeployments(projectId: number) {
return apiRequest<PagesDeployment[]>(`/pages/${projectId}/deployments`);
}
export function uploadPagesDeployment(
projectId: number,
file: File,
entryFile = 'index.html',
onProgress?: (percent: number) => void,
) {
if (!onProgress) {
const formData = new FormData();
formData.append('package', file);
formData.append('entry_file', entryFile);
return apiRequest<PagesDeployment>(`/pages/${projectId}/deployments/upload`, {
method: 'POST',
body: formData,
});
}
return new Promise<PagesDeployment>((resolve, reject) => {
const formData = new FormData();
formData.append('package', file);
formData.append('entry_file', entryFile);
const xhr = new XMLHttpRequest();
xhr.open('POST', getApiUrl(`/pages/${projectId}/deployments/upload`));
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
});
xhr.onload = () => {
let payload: ApiEnvelope<PagesDeployment> | null = null;
try {
payload = JSON.parse(xhr.responseText) as ApiEnvelope<PagesDeployment>;
} catch {
payload = null;
}
if (xhr.status >= 200 && xhr.status < 300) {
if (payload && payload.success) {
resolve(payload.data);
} else {
reject(new ApiError(payload?.message || '请求失败', xhr.status));
}
} else {
reject(new ApiError(payload?.message || `请求失败(${xhr.status})`, xhr.status));
}
};
xhr.onerror = () => {
reject(new ApiError('网络请求失败', 0));
};
xhr.send(formData);
});
}
export function activatePagesDeployment(
projectId: number,
deploymentId: number,
) {
return apiRequest<PagesProject>(
`/pages/${projectId}/deployments/${deploymentId}/activate`,
{ method: 'POST' },
);
}
export function deletePagesDeployment(projectId: number, deploymentId: number) {
return apiRequest<void>(
`/pages/${projectId}/deployments/${deploymentId}/delete`,
{ method: 'POST' },
);
}
@@ -0,0 +1,730 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useState, type FormEvent } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import {
activatePagesDeployment,
createPagesProject,
deletePagesDeployment,
deletePagesProject,
getPagesProject,
getPagesDeployments,
getPagesProjects,
updatePagesProject,
uploadPagesDeployment,
} from '@/features/pages/api/pages';
import type { PagesProject } from '@/features/pages/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
const projectsQueryKey = ['pages-projects'];
function projectQueryKey(projectId: string | number) {
return ['pages-project', String(projectId)];
}
function deploymentsQueryKey(projectId: string | number) {
return ['pages-deployments', Number(projectId)];
}
function formatBytes(value: number) {
if (value < 1024) {
return `${value} B`;
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
function formatDate(value?: string | null) {
if (!value) {
return '未激活';
}
return new Date(value).toLocaleString();
}
export function PagesPage() {
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
const projectsQuery = useQuery({
queryKey: projectsQueryKey,
queryFn: getPagesProjects,
});
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-[var(--foreground-secondary)]">
OpenFlare Pages
</p>
<h1 className="text-2xl font-semibold text-[var(--foreground-primary)]">
边缘静态站点托管
</h1>
<p className="max-w-3xl text-sm leading-6 text-[var(--foreground-secondary)]">
创建 Pages 项目,上传已构建的 zip 静态资源包,然后在规则中选择 Pages
项目作为上游。发布后 Agent
会拉取部署包并在边缘节点本地服务静态文件。
</p>
</div>
<PrimaryButton
type="button"
className="w-full lg:w-auto"
onClick={() => setCreateModalOpen(true)}
>
新建 Pages 项目
</PrimaryButton>
</div>
<div className="space-y-4">
{projectsQuery.isLoading ? (
<AppCard>正在加载 Pages 项目...</AppCard>
) : projectsQuery.error ? (
<AppCard>
<p className="text-sm text-[var(--status-danger-foreground)]">
{projectsQuery.error.message}
</p>
</AppCard>
) : (projectsQuery.data ?? []).length === 0 ? (
<AppCard
title="还没有 Pages 项目"
description="先创建一个项目,再上传静态资源包。"
action={
<SecondaryButton
type="button"
onClick={() => setCreateModalOpen(true)}
>
新建 Pages 项目
</SecondaryButton>
}
>
</AppCard>
) : (
(projectsQuery.data ?? []).map((project) => (
<PagesProjectListItem key={project.id} project={project} />
))
)}
</div>
<PagesProjectCreateModal
isOpen={isCreateModalOpen}
onClose={() => setCreateModalOpen(false)}
/>
</div>
);
}
export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
const router = useRouter();
const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [entryFile, setEntryFile] = useState('index.html');
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [isEditModalOpen, setEditModalOpen] = useState(false);
const parsedProjectId = Number(projectId);
const projectQuery = useQuery({
queryKey: projectQueryKey(projectId),
queryFn: () => getPagesProject(parsedProjectId),
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
});
const deploymentsQuery = useQuery({
queryKey: deploymentsQueryKey(parsedProjectId),
queryFn: () => getPagesDeployments(parsedProjectId),
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
});
const uploadMutation = useMutation({
mutationFn: () => {
if (!file) {
throw new Error('请选择 zip 文件');
}
setUploadProgress(0);
return uploadPagesDeployment(parsedProjectId, file, entryFile, (percent) => {
setUploadProgress(percent);
});
},
onSuccess: () => {
setFile(null);
setUploadProgress(null);
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(parsedProjectId),
});
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
onError: () => {
setUploadProgress(null);
},
});
const activateMutation = useMutation({
mutationFn: (deploymentId: number) =>
activatePagesDeployment(parsedProjectId, deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(parsedProjectId),
});
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
const deleteDeploymentMutation = useMutation({
mutationFn: (deploymentId: number) =>
deletePagesDeployment(parsedProjectId, deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(parsedProjectId),
});
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
const deleteProjectMutation = useMutation({
mutationFn: () => deletePagesProject(parsedProjectId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
router.push('/pages');
},
});
if (projectId === '' || !Number.isFinite(parsedProjectId)) {
return (
<EmptyState
title="Pages 项目不存在"
description="缺少有效的 Pages 项目 ID,请从项目列表重新进入。"
/>
);
}
if (projectQuery.isLoading) {
return <LoadingState />;
}
if (projectQuery.isError) {
return (
<ErrorState
title="Pages 项目加载失败"
description={projectQuery.error.message}
/>
);
}
const project = projectQuery.data;
if (!project) {
return (
<EmptyState
title="Pages 项目不存在"
description="该项目可能已被删除,或当前 ID 无法匹配到项目记录。"
/>
);
}
const handleDeleteProject = () => {
if (!window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) {
return;
}
deleteProjectMutation.mutate();
};
return (
<div className="space-y-6">
<PageHeader
title={project.name}
description={`${project.slug} · Pages 静态站点项目详情`}
action={
<>
<Link
href="/pages"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回列表
</Link>
<SecondaryButton
type="button"
onClick={() => setEditModalOpen(true)}
>
编辑项目
</SecondaryButton>
<DangerButton
type="button"
disabled={deleteProjectMutation.isPending}
onClick={handleDeleteProject}
>
删除项目
</DangerButton>
</>
}
/>
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<AppCard
title="上传部署包"
description="上传已构建的 zip 静态资源包,默认入口 index.html。"
>
<div className="space-y-4">
<ResourceField
label="部署包"
hint="仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件。"
>
<ResourceInput
type="file"
accept=".zip,application/zip"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
</ResourceField>
<ResourceField label="入口文件">
<ResourceInput
value={entryFile}
onChange={(event) => setEntryFile(event.target.value)}
/>
</ResourceField>
{uploadProgress !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
<span>上传进度</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-muted)]">
<div
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-300 ease-out"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
<PrimaryButton
type="button"
disabled={!file || uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
>
{uploadMutation.isPending
? `上传中 (${uploadProgress ?? 0}%)...`
: '上传部署'}
</PrimaryButton>
{uploadMutation.error ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
{uploadMutation.error.message}
</p>
) : null}
</div>
</AppCard>
<AppCard
title="部署历史"
description="部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。"
>
{deploymentsQuery.isLoading ? (
<p className="text-sm text-[var(--foreground-secondary)]">
加载中...
</p>
) : deploymentsQuery.isError ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
{deploymentsQuery.error.message}
</p>
) : (deploymentsQuery.data ?? []).length === 0 ? (
<EmptyState
title="暂无部署"
description="上传 zip 部署包后,可以在这里激活某个部署版本。"
/>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
{(deploymentsQuery.data ?? []).map((deployment) => (
<div
key={deployment.id}
className="flex flex-col gap-3 border-b border-[var(--border-default)] p-4 last:border-b-0 md:flex-row md:items-center md:justify-between"
>
<div>
<p className="text-sm font-medium text-[var(--foreground-primary)]">
#{deployment.deployment_number}{' '}
{deployment.status === 'active' ? '· 已激活' : ''}
</p>
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
{deployment.checksum.slice(0, 16)} ·{' '}
{deployment.file_count} files ·{' '}
{formatBytes(deployment.total_size)}
</p>
</div>
<div className="flex gap-2">
<SecondaryButton
type="button"
disabled={
deployment.status === 'active' ||
activateMutation.isPending
}
onClick={() => {
if (
window.confirm(
`确认激活部署 #${deployment.deployment_number} 吗?`,
)
) {
activateMutation.mutate(deployment.id);
}
}}
>
激活
</SecondaryButton>
<DangerButton
type="button"
disabled={
deployment.status === 'active' ||
deleteDeploymentMutation.isPending
}
onClick={() => {
if (
window.confirm(
`确认删除部署 #${deployment.deployment_number} 吗?`,
)
) {
deleteDeploymentMutation.mutate(deployment.id);
}
}}
>
删除
</DangerButton>
</div>
</div>
))}
</div>
)}
</AppCard>
</div>
<PagesProjectEditModal
isOpen={isEditModalOpen}
onClose={() => setEditModalOpen(false)}
project={project}
/>
</div>
);
}
function PagesProjectEditModal({
isOpen,
onClose,
project,
}: {
isOpen: boolean;
onClose: () => void;
project: PagesProject;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState(project.name);
const [slug, setSlug] = useState(project.slug);
const [description, setDescription] = useState(project.description || '');
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(project.spa_fallback_enabled);
const [spaFallbackPath, setSpaFallbackPath] = useState(project.spa_fallback_path);
useEffect(() => {
setName(project.name);
setSlug(project.slug);
setDescription(project.description || '');
setSpaFallbackEnabled(project.spa_fallback_enabled);
setSpaFallbackPath(project.spa_fallback_path);
}, [project]);
const updateMutation = useMutation({
mutationFn: () =>
updatePagesProject(project.id, {
name,
slug,
description,
enabled: project.enabled,
spa_fallback_enabled: spaFallbackEnabled,
spa_fallback_path: spaFallbackPath,
}),
onSuccess: () => {
onClose();
queryClient.invalidateQueries({ queryKey: projectQueryKey(project.id) });
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
function handleUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
updateMutation.mutate();
}
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title="编辑 Pages 项目"
description="修改静态站点项目的基础配置。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="pages-project-edit-form"
disabled={updateMutation.isPending || name.trim() === ''}
>
{updateMutation.isPending ? '保存中...' : '保存修改'}
</PrimaryButton>
</div>
}
>
<form
id="pages-project-edit-form"
className="grid gap-4 md:grid-cols-2"
onSubmit={handleUpdate}
>
<ResourceField label="项目名称">
<ResourceInput
value={name}
placeholder="Marketing Site"
onChange={(event) => setName(event.target.value)}
required
/>
</ResourceField>
<ResourceField label="项目标识" hint="留空时会按名称自动生成。">
<ResourceInput
value={slug}
placeholder="marketing-site"
onChange={(event) => setSlug(event.target.value)}
/>
</ResourceField>
<ResourceField label="描述" className="md:col-span-2">
<ResourceInput
value={description}
placeholder="这个项目托管的静态站点用途"
onChange={(event) => setDescription(event.target.value)}
/>
</ResourceField>
<ToggleField
label="启用 SPA fallback"
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
checked={spaFallbackEnabled}
onChange={setSpaFallbackEnabled}
/>
<ResourceField
label="SPA 回退路径"
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
>
<ResourceInput
value={spaFallbackPath}
placeholder="/index.html"
disabled={!spaFallbackEnabled}
onChange={(event) => setSpaFallbackPath(event.target.value)}
/>
</ResourceField>
{updateMutation.error ? (
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
{updateMutation.error.message}
</p>
) : null}
</form>
</AppModal>
);
}
function PagesProjectCreateModal({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [description, setDescription] = useState('');
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(false);
const [spaFallbackPath, setSpaFallbackPath] = useState('/index.html');
const resetForm = () => {
setName('');
setSlug('');
setDescription('');
setSpaFallbackEnabled(false);
setSpaFallbackPath('/index.html');
};
const closeModal = () => {
resetForm();
onClose();
};
const createMutation = useMutation({
mutationFn: () =>
createPagesProject({
name,
slug,
description,
enabled: true,
spa_fallback_enabled: spaFallbackEnabled,
spa_fallback_path: spaFallbackPath,
}),
onSuccess: () => {
resetForm();
onClose();
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
createMutation.mutate();
}
return (
<AppModal
isOpen={isOpen}
onClose={closeModal}
title="新建 Pages 项目"
description="配置静态站点项目的基础信息。创建后再上传已构建的 zip 部署包。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton type="button" onClick={closeModal}>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="pages-project-create-form"
disabled={createMutation.isPending || name.trim() === ''}
>
{createMutation.isPending ? '创建中...' : '创建项目'}
</PrimaryButton>
</div>
}
>
<form
id="pages-project-create-form"
className="grid gap-4 md:grid-cols-2"
onSubmit={handleCreate}
>
<ResourceField label="项目名称">
<ResourceInput
value={name}
placeholder="Marketing Site"
onChange={(event) => setName(event.target.value)}
required
/>
</ResourceField>
<ResourceField label="项目标识" hint="留空时会按名称自动生成。">
<ResourceInput
value={slug}
placeholder="marketing-site"
onChange={(event) => setSlug(event.target.value)}
/>
</ResourceField>
<ResourceField label="描述" className="md:col-span-2">
<ResourceInput
value={description}
placeholder="这个项目托管的静态站点用途"
onChange={(event) => setDescription(event.target.value)}
/>
</ResourceField>
<ToggleField
label="启用 SPA fallback"
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
checked={spaFallbackEnabled}
onChange={setSpaFallbackEnabled}
/>
<ResourceField
label="SPA 回退路径"
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
>
<ResourceInput
value={spaFallbackPath}
placeholder="/index.html"
disabled={!spaFallbackEnabled}
onChange={(event) => setSpaFallbackPath(event.target.value)}
/>
</ResourceField>
{createMutation.error ? (
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
{createMutation.error.message}
</p>
) : null}
</form>
</AppModal>
);
}
function PagesProjectListItem({ project }: { project: PagesProject }) {
return (
<Link
href={`/pages/detail?id=${project.id}`}
className="group block rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] p-5 shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[var(--border-strong)] hover:shadow-[var(--shadow-soft)]"
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
{project.name}
</h2>
<StatusBadge
label={project.enabled ? '已启用' : '已停用'}
variant={project.enabled ? 'success' : 'warning'}
/>
<StatusBadge
label={project.spa_fallback_enabled ? 'SPA fallback' : '严格 404'}
variant={project.spa_fallback_enabled ? 'info' : 'warning'}
/>
</div>
<p className="text-sm text-[var(--foreground-secondary)]">
{project.slug}
</p>
{project.description ? (
<p className="line-clamp-2 text-sm leading-6 text-[var(--foreground-secondary)]">
{project.description}
</p>
) : null}
{project.spa_fallback_enabled ? (
<p className="text-xs text-[var(--foreground-secondary)]">
回退路径:{project.spa_fallback_path || '/index.html'}
</p>
) : null}
</div>
<div className="grid shrink-0 grid-cols-2 gap-3 text-sm md:min-w-80">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
<p className="text-xs text-[var(--foreground-secondary)]">部署数</p>
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
{project.deployment_count}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
<p className="text-xs text-[var(--foreground-secondary)]">
当前激活
</p>
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
{project.active_deployment
? `#${project.active_deployment.deployment_number}`
: '暂无'}
</p>
</div>
</div>
</div>
<div className="mt-4 flex items-center justify-between border-t border-[var(--border-default)] pt-4">
<p className="text-xs text-[var(--foreground-secondary)]">
激活时间:{formatDate(project.active_deployment?.activated_at)}
</p>
<span className="text-sm font-medium text-[var(--brand-primary)] transition group-hover:translate-x-1">
查看详情 →
</span>
</div>
</Link>
);
}
@@ -0,0 +1,37 @@
export interface PagesDeployment {
id: number;
project_id: number;
deployment_number: number;
checksum: string;
status: 'uploaded' | 'active';
file_count: number;
total_size: number;
entry_file: string;
created_by: string;
created_at: string;
activated_at?: string | null;
}
export interface PagesProject {
id: number;
name: string;
slug: string;
description: string;
enabled: boolean;
spa_fallback_enabled: boolean;
spa_fallback_path: string;
active_deployment_id?: number | null;
active_deployment?: PagesDeployment | null;
deployment_count: number;
created_at: string;
updated_at: string;
}
export interface PagesProjectPayload {
name: string;
slug: string;
description: string;
enabled: boolean;
spa_fallback_enabled: boolean;
spa_fallback_path: string;
}
@@ -15,6 +15,7 @@ import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
import { getPagesProjects } from '@/features/pages/api/pages';
import {
getProxyRoute,
updateProxyRoute,
@@ -157,12 +158,13 @@ const rateLimitSchema = z
const reverseProxySchema = z
.object({
upstream_type: z.enum(['direct', 'tunnel']),
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
origin_urls_text: z.string().trim(),
origin_host: z.string(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
custom_headers_text: z.string(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
@@ -184,7 +186,7 @@ const reverseProxySchema = z
});
}
}
} else {
} else if (value.upstream_type === 'tunnel') {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
@@ -199,6 +201,12 @@ const reverseProxySchema = z
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
} else if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pages_project_id'],
message: '请选择 Pages 项目',
});
}
const originHostError = validateOriginHost(value.origin_host);
@@ -552,8 +560,17 @@ function ReverseProxySection({
queryKey: ['nodes'],
queryFn: getNodes,
});
const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client');
const pagesProjectsQuery = useQuery({
queryKey: ['pages-projects'],
queryFn: getPagesProjects,
});
const tunnelClients = (tunnelsQuery.data ?? []).filter(
(node) => node.node_type === 'tunnel_client',
);
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
(project) => project.enabled && project.active_deployment_id,
);
const form = useForm<ReverseProxyValues>({
resolver: zodResolver(reverseProxySchema),
@@ -564,6 +581,7 @@ function ReverseProxySection({
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
},
@@ -577,6 +595,7 @@ function ReverseProxySection({
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
});
@@ -609,12 +628,17 @@ function ReverseProxySection({
originPort = primaryOrigin.port;
originUri = primaryOrigin.uri;
upstreams = urls.slice(1);
} else {
} else if (values.upstream_type === 'tunnel') {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
originScheme = values.tunnel_target_protocol as 'http' | 'https';
originAddress = values.tunnel_target_addr || '';
} else {
originUrl = 'http://127.0.0.1';
originScheme = 'http';
originAddress = '127.0.0.1';
originPort = '80';
}
const { headers } = parseCustomHeadersText(
values.custom_headers_text,
);
@@ -632,9 +656,22 @@ function ReverseProxySection({
custom_headers: headers,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
tunnel_node_id:
values.upstream_type === 'tunnel' && values.tunnel_id
? Number(values.tunnel_id)
: null,
tunnel_target_addr:
values.upstream_type === 'tunnel'
? values.tunnel_target_addr
: '',
tunnel_target_protocol:
values.upstream_type === 'tunnel'
? values.tunnel_target_protocol
: '',
pages_project_id:
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
: null,
}),
{ message: '反向代理设置已保存。' },
);
@@ -661,6 +698,15 @@ function ReverseProxySection({
/>
内网穿透 (Tunnel)
</label>
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
<input
type="radio"
value="pages"
{...form.register('upstream_type')}
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
Pages 静态站点
</label>
</div>
</div>
@@ -679,7 +725,7 @@ function ReverseProxySection({
{...form.register('origin_urls_text')}
/>
</ResourceField>
) : (
) : form.watch('upstream_type') === 'tunnel' ? (
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
<ResourceField
label="选择内网穿透隧道"
@@ -724,6 +770,26 @@ function ReverseProxySection({
/>
</ResourceField>
</div>
) : (
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
<ResourceField
label="选择 Pages 项目"
hint="仅显示已启用且已有激活部署的 Pages 项目。"
error={form.formState.errors.pages_project_id?.message}
>
<select
{...form.register('pages_project_id')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{pagesProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name} ({project.slug})
</option>
))}
</select>
</ResourceField>
</div>
)}
<ResourceField
@@ -8,6 +8,7 @@ import { z } from 'zod';
import { Drawer } from '@/components/ui/drawer';
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
import { getPagesProjects } from '@/features/pages/api/pages';
import { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes';
import {
DomainListInput,
@@ -40,11 +41,12 @@ const createWebsiteSchema = z
.object({
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
domain_rows: z.array(domainRowSchema).min(1),
upstream_type: z.enum(['direct', 'tunnel']),
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
origin_urls_text: z.string().trim(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
enabled: z.boolean(),
redirect_http: z.boolean(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
@@ -79,7 +81,7 @@ const createWebsiteSchema = z
});
}
}
} else {
} else if (value.upstream_type === 'tunnel') {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
@@ -94,6 +96,12 @@ const createWebsiteSchema = z
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
} else if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pages_project_id'],
message: '请选择 Pages 项目',
});
}
const selectedCertificateCount = new Set(
@@ -120,6 +128,7 @@ const defaultValues: CreateWebsiteFormValues = {
tunnel_id: '',
tunnel_target_addr: '',
tunnel_target_protocol: 'http',
pages_project_id: '',
enabled: true,
redirect_http: false,
remark: '',
@@ -177,7 +186,17 @@ export function ProxyRouteCreateDrawer({
queryFn: getNodes,
enabled: open,
});
const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client');
const pagesProjectsQuery = useQuery({
queryKey: ['pages-projects'],
queryFn: getPagesProjects,
enabled: open,
});
const tunnelClients = (tunnelsQuery.data ?? []).filter(
(node) => node.node_type === 'tunnel_client',
);
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
(project) => project.enabled && project.active_deployment_id,
);
const combinedDomainSuggestions = useMemo(
() => [
@@ -199,11 +218,11 @@ export function ProxyRouteCreateDrawer({
const selectedCertIDs = normalizeSelectedCertificateIDs(
values.domain_rows,
);
let originUrl = '';
const originHost = '';
let upstreams: string[] = [];
if (values.upstream_type === 'direct') {
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
@@ -214,8 +233,10 @@ export function ProxyRouteCreateDrawer({
primaryOrigin.uri,
);
upstreams = urls.slice(1);
} else {
} else if (values.upstream_type === 'tunnel') {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
} else {
originUrl = 'http://127.0.0.1';
}
return createProxyRoute({
@@ -224,10 +245,22 @@ export function ProxyRouteCreateDrawer({
domains,
origin_id: null,
origin_url: originUrl,
origin_scheme: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).scheme : 'http',
origin_address: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).address : values.tunnel_target_addr || '',
origin_port: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).port : '80',
origin_uri: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).uri : '',
origin_scheme:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).scheme
: 'http',
origin_address:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).address
: values.tunnel_target_addr || '127.0.0.1',
origin_port:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).port
: '80',
origin_uri:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).uri
: '',
origin_host: originHost,
upstreams,
enabled: values.enabled,
@@ -249,9 +282,20 @@ export function ProxyRouteCreateDrawer({
basic_auth_enabled: false,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
tunnel_node_id:
values.upstream_type === 'tunnel' && values.tunnel_id
? Number(values.tunnel_id)
: null,
tunnel_target_addr:
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol:
values.upstream_type === 'tunnel'
? values.tunnel_target_protocol
: '',
pages_project_id:
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
: null,
});
},
onSuccess: (route) => {
@@ -359,6 +403,15 @@ export function ProxyRouteCreateDrawer({
/>
内网穿透 (Tunnel)
</label>
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
<input
type="radio"
value="pages"
{...form.register('upstream_type')}
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
Pages 静态站点
</label>
</div>
</div>
@@ -376,7 +429,7 @@ export function ProxyRouteCreateDrawer({
{...form.register('origin_urls_text')}
/>
</ResourceField>
) : (
) : form.watch('upstream_type') === 'tunnel' ? (
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
<ResourceField
label="选择内网穿透隧道"
@@ -421,6 +474,26 @@ export function ProxyRouteCreateDrawer({
/>
</ResourceField>
</div>
) : (
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
<ResourceField
label="选择 Pages 项目"
hint="仅显示已启用且已有激活部署的 Pages 项目。"
error={form.formState.errors.pages_project_id?.message}
>
<select
{...form.register('pages_project_id')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{pagesProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name} ({project.slug})
</option>
))}
</select>
</ResourceField>
</div>
)}
<ToggleField
@@ -267,7 +267,10 @@ export function buildPayloadFromRoute(
route: ProxyRouteItem,
overrides: Partial<ProxyRouteMutationPayload>,
): ProxyRouteMutationPayload {
const primaryOrigin = parseOriginUrl(route.origin_url);
const primaryOrigin =
route.upstream_type === 'pages'
? parseOriginUrl('http://127.0.0.1')
: parseOriginUrl(route.origin_url);
return {
site_name: route.site_name,
@@ -304,11 +307,17 @@ export function buildPayloadFromRoute(
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: route.tunnel_target_protocol || '',
pages_project_id: route.pages_project_id ?? null,
...overrides,
};
}
export function getUpstreamSummary(route: ProxyRouteItem) {
if (route.upstream_type === 'pages') {
return route.pages_project_id
? `Pages 项目 #${route.pages_project_id}`
: 'Pages 项目未绑定';
}
if (route.upstream_type === 'tunnel') {
const protocol = route.tunnel_target_protocol || 'http';
const target = route.tunnel_target_addr || '未配置目标';
@@ -53,11 +53,12 @@ export interface ProxyRouteItem {
basic_auth_username: string;
basic_auth_password: string;
remark: string;
upstream_type: 'direct' | 'tunnel';
upstream_type: 'direct' | 'tunnel' | 'pages';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
pages_project_id?: number | null;
created_at: string;
updated_at: string;
}
@@ -93,11 +94,12 @@ export interface ProxyRouteMutationPayload {
basic_auth_username?: string;
basic_auth_password?: string;
remark: string;
upstream_type?: 'direct' | 'tunnel';
upstream_type?: 'direct' | 'tunnel' | 'pages';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
pages_project_id?: number | null;
}
export interface TlsCertificateItem {
@@ -126,3 +126,9 @@ export function bindEmail(email: string, code: string) {
export function getAboutContent() {
return apiRequest<string>('/about');
}
export function syncUptimeKuma() {
return apiRequest<void>('/uptimekuma/sync', {
method: 'POST',
});
}
@@ -31,8 +31,10 @@ import {
rotateBootstrapToken,
updateOptions,
updateSelf,
syncUptimeKuma,
} from '@/features/settings/api/settings';
import { AuthSourceModal } from '@/features/settings/components/auth-source-modal';
import { UptimeKumaSiteSelectModal } from './uptimekuma-modal';
import type {
BootstrapTokenPayload,
DatabaseCleanupResult,
@@ -87,6 +89,17 @@ const defaultOperationFields = {
NodeOfflineThreshold: '120000',
AgentUpdateRepo: 'Rain-kl/OpenFlare',
GeoIPProvider: 'ipinfo',
UptimeKumaEnabled: false,
UptimeKumaUrl: '',
UptimeKumaUsername: '',
UptimeKumaPassword: '',
UptimeKumaMonitorScope: 'all',
UptimeKumaSelectedSites: '',
UptimeKumaSyncInterval: '5',
UptimeKumaInterval: '60',
UptimeKumaRetry: '0',
UptimeKumaRetryInterval: '60',
UptimeKumaTimeout: '48',
OpenRestyDefaultServerReturnStatus: '421',
OpenRestyWorkerProcesses: 'auto',
OpenRestyWorkerConnections: '4096',
@@ -255,6 +268,7 @@ export function SettingsPage() {
const [cleanupModalState, setCleanupModalState] =
useState<CleanupModalState | null>(null);
const [cleanupRetentionDays, setCleanupRetentionDays] = useState('');
const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false);
const isRoot = (user?.role ?? 0) >= 100;
@@ -428,6 +442,17 @@ export function SettingsPage() {
GlobalWebRateLimitDuration: optionMap.GlobalWebRateLimitDuration ?? '180',
CriticalRateLimitNum: optionMap.CriticalRateLimitNum ?? '100',
CriticalRateLimitDuration: optionMap.CriticalRateLimitDuration ?? '1200',
UptimeKumaEnabled: toBoolean(optionMap.UptimeKumaEnabled, false),
UptimeKumaUrl: optionMap.UptimeKumaUrl ?? '',
UptimeKumaUsername: optionMap.UptimeKumaUsername ?? '',
UptimeKumaPassword: '',
UptimeKumaMonitorScope: optionMap.UptimeKumaMonitorScope ?? 'all',
UptimeKumaSelectedSites: optionMap.UptimeKumaSelectedSites ?? '',
UptimeKumaSyncInterval: optionMap.UptimeKumaSyncInterval ?? '5',
UptimeKumaInterval: optionMap.UptimeKumaInterval ?? '60',
UptimeKumaRetry: optionMap.UptimeKumaRetry ?? '0',
UptimeKumaRetryInterval: optionMap.UptimeKumaRetryInterval ?? '60',
UptimeKumaTimeout: optionMap.UptimeKumaTimeout ?? '48',
ServerAddress: resolvedServerAddress,
});
@@ -656,6 +681,64 @@ export function SettingsPage() {
});
};
const handleUptimeKumaSave = () => {
void runBusyAction('uptimekuma-save', async () => {
const syncInt = Number.parseInt(operationFields.UptimeKumaSyncInterval, 10);
const interval = Number.parseInt(operationFields.UptimeKumaInterval, 10);
const retry = Number.parseInt(operationFields.UptimeKumaRetry, 10);
const retryInt = Number.parseInt(operationFields.UptimeKumaRetryInterval, 10);
const timeout = Number.parseInt(operationFields.UptimeKumaTimeout, 10);
if (operationFields.UptimeKumaEnabled) {
if (!operationFields.UptimeKumaUrl.trim()) {
throw new Error('请输入 Uptime Kuma 地址。');
}
if (!operationFields.UptimeKumaUsername.trim()) {
throw new Error('请输入 Uptime Kuma 用户名。');
}
}
if (Number.isNaN(syncInt) || syncInt <= 0) {
throw new Error('同步间隔必须为正整数。');
}
if (Number.isNaN(interval) || interval <= 0) {
throw new Error('心跳间隔必须为正整数。');
}
if (Number.isNaN(retry) || retry < 0) {
throw new Error('重试次数必须为非负整数。');
}
if (Number.isNaN(retryInt) || retryInt <= 0) {
throw new Error('心跳重试间隔必须为正整数。');
}
if (Number.isNaN(timeout) || timeout <= 0) {
throw new Error('请求超时必须为正整数。');
}
await saveOptionEntries(
[
['UptimeKumaEnabled', String(operationFields.UptimeKumaEnabled)],
['UptimeKumaUrl', operationFields.UptimeKumaUrl.trim()],
['UptimeKumaUsername', operationFields.UptimeKumaUsername.trim()],
['UptimeKumaPassword', operationFields.UptimeKumaPassword],
['UptimeKumaMonitorScope', operationFields.UptimeKumaMonitorScope],
['UptimeKumaSelectedSites', operationFields.UptimeKumaSelectedSites],
['UptimeKumaSyncInterval', String(syncInt)],
['UptimeKumaInterval', String(interval)],
['UptimeKumaRetry', String(retry)],
['UptimeKumaRetryInterval', String(retryInt)],
['UptimeKumaTimeout', String(timeout)],
],
'Uptime Kuma 设置已保存。',
);
});
};
const handleUptimeKumaSync = () => {
void runBusyAction('uptimekuma-sync', async () => {
await syncUptimeKuma();
setFeedback({ tone: 'success', message: '同步任务已成功执行!' });
});
};
const renderTabContent = () => {
if (profileQuery.isLoading || publicStatusQuery.isLoading) {
return <LoadingState />;
@@ -1307,6 +1390,198 @@ export function SettingsPage() {
</div>
)}
</AppCard>
<AppCard
title="Uptime Kuma 集成"
description="通过 API 将 OpenFlare 反代站点同步至 Uptime Kuma 监控。"
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={handleUptimeKumaSync}
disabled={busyKey === 'uptimekuma-sync' || !operationFields.UptimeKumaEnabled}
>
{busyKey === 'uptimekuma-sync' ? '同步中...' : '立即同步'}
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleUptimeKumaSave}
disabled={busyKey === 'uptimekuma-save'}
>
{busyKey === 'uptimekuma-save' ? '保存中...' : '保存设置'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<ToggleField
label="开启 Uptime Kuma"
description="启用后会自动差分同步反代规则站点到 Uptime Kuma 实例。"
checked={operationFields.UptimeKumaEnabled}
onChange={(checked) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaEnabled: checked,
}))
}
/>
{operationFields.UptimeKumaEnabled ? (
<div className="space-y-5 border-t border-[var(--border-default)] pt-5">
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="Uptime Kuma 地址" hint="例如 http://192.168.1.100:3001">
<ResourceInput
value={operationFields.UptimeKumaUrl}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaUrl: event.target.value,
}))
}
placeholder="http://localhost:3001"
/>
</ResourceField>
<ResourceField label="Uptime Kuma 用户名">
<ResourceInput
value={operationFields.UptimeKumaUsername}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaUsername: event.target.value,
}))
}
placeholder="请输入用户名"
/>
</ResourceField>
</div>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField
label="Uptime Kuma 密码"
hint="安全原因不显示历史密码,留空表示不更新。"
>
<ResourceInput
type="password"
value={operationFields.UptimeKumaPassword}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaPassword: event.target.value,
}))
}
placeholder="请输入密码(留空表示不更新)"
/>
</ResourceField>
<ResourceField label="同步间隔 (分钟)">
<ResourceInput
type="number"
value={operationFields.UptimeKumaSyncInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaSyncInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="监控范围">
<ResourceSelect
value={operationFields.UptimeKumaMonitorScope}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaMonitorScope: event.target.value,
}))
}
>
<option value="all">全部站点</option>
<option value="selected">选择站点</option>
</ResourceSelect>
</ResourceField>
</div>
{operationFields.UptimeKumaMonitorScope === 'selected' ? (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--foreground-primary)]">已选站点</span>
<SecondaryButton type="button" onClick={() => setUptimeKumaModalOpen(true)}>
选择监控站点
</SecondaryButton>
</div>
<div className="text-xs text-[var(--foreground-secondary)] break-all max-h-[80px] overflow-y-auto leading-5">
{operationFields.UptimeKumaSelectedSites
? operationFields.UptimeKumaSelectedSites.split(',').join(', ')
: '未选择任何站点,同步不会执行。'}
</div>
</div>
) : null}
<div className="border-t border-[var(--border-default)] pt-5 space-y-4">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
Uptime Kuma 属性配置
</p>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="心跳间隔 (检测频率秒)" hint="服务检测频率,单位:秒">
<ResourceInput
type="number"
value={operationFields.UptimeKumaInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="重试次数" hint="服务被标记为故障前的最大重试次数">
<ResourceInput
type="number"
value={operationFields.UptimeKumaRetry}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaRetry: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="心跳重试间隔 (秒)" hint="重试间隔时间,单位:秒">
<ResourceInput
type="number"
value={operationFields.UptimeKumaRetryInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaRetryInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="请求超时 (秒)" hint="超时断开时间,单位:秒">
<ResourceInput
type="number"
value={operationFields.UptimeKumaTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaTimeout: event.target.value,
}))
}
/>
</ResourceField>
</div>
</div>
</div>
) : null}
</div>
</AppCard>
<AppCard title="版本与构建信息">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -2050,6 +2325,22 @@ export function SettingsPage() {
}}
/>
<UptimeKumaSiteSelectModal
isOpen={uptimeKumaModalOpen}
selectedSites={
operationFields.UptimeKumaSelectedSites
? operationFields.UptimeKumaSelectedSites.split(',')
: []
}
onClose={() => setUptimeKumaModalOpen(false)}
onSave={(sites) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaSelectedSites: sites.join(','),
}))
}
/>
<AppModal
isOpen={cleanupModalState !== null}
title={`清理${cleanupModalState?.label ?? ''}`}
@@ -0,0 +1,211 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '加载站点列表失败。';
}
export function UptimeKumaSiteSelectModal({
isOpen,
selectedSites,
onClose,
onSave,
}: {
isOpen: boolean;
selectedSites: string[];
onClose: () => void;
onSave: (sites: string[]) => void;
}) {
const [searchTerm, setSearchTerm] = useState('');
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
const { data: routes = [], isLoading, error } = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
enabled: isOpen,
});
useEffect(() => {
if (isOpen) {
setTempSelected(new Set(selectedSites.map(s => s.trim()).filter(Boolean)));
setSearchTerm('');
}
}, [isOpen, selectedSites]);
const toggleSite = (siteName: string) => {
setTempSelected((prev) => {
const next = new Set(prev);
if (next.has(siteName)) {
next.delete(siteName);
} else {
next.add(siteName);
}
return next;
});
};
const handleSelectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.add(route.site_name);
});
return next;
});
};
const handleDeselectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.delete(route.site_name);
});
return next;
});
};
const handleSave = () => {
onSave(Array.from(tempSelected));
onClose();
};
const filteredRoutes = routes.filter(
(route) =>
route.site_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
route.primary_domain.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<AppModal
isOpen={isOpen}
title="选择监控站点"
description="请选择要同步到 Uptime Kuma 监控的站点,支持按站点名称和域名搜索。"
size="lg"
onClose={onClose}
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton type="button" onClick={handleSave}>
保存选择
</PrimaryButton>
</div>
}
>
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex-1">
<ResourceField label="搜索站点">
<ResourceInput
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="按名称或域名搜索..."
/>
</ResourceField>
</div>
<div className="flex gap-2 sm:mt-6">
<SecondaryButton type="button" onClick={handleSelectAll}>
全选过滤项
</SecondaryButton>
<SecondaryButton type="button" onClick={handleDeselectAll}>
清空过滤项
</SecondaryButton>
</div>
</div>
{isLoading ? <LoadingState /> : null}
{error ? (
<ErrorState
title="站点加载失败"
description={getErrorMessage(error)}
/>
) : null}
{!isLoading && !error && routes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
暂无可用的代理站点。
</div>
) : null}
{!isLoading && !error && routes.length > 0 ? (
<div className="max-h-[350px] overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)]">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
<tr>
<th className="w-12 px-4 py-3">选择</th>
<th className="px-4 py-3 font-medium">站点名称</th>
<th className="px-4 py-3 font-medium">主域名</th>
<th className="px-4 py-3 font-medium">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{filteredRoutes.map((route) => {
const isChecked = tempSelected.has(route.site_name);
return (
<tr
key={route.id}
onClick={() => toggleSite(route.site_name)}
className="cursor-pointer hover:bg-[var(--surface-elevated)]"
>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleSite(route.site_name)}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</td>
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
{route.site_name}
</td>
<td className="px-4 py-3 text-[var(--foreground-secondary)]">
{route.primary_domain}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
route.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{route.enabled ? '启用' : '禁用'}
</span>
</td>
</tr>
);
})}
{filteredRoutes.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-[var(--foreground-secondary)]">
无匹配的站点
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
<div className="text-xs text-[var(--foreground-muted)] text-right">
已选择 {tempSelected.size} 个监控站点
</div>
</div>
</AppModal>
);
}
@@ -16,6 +16,11 @@ export const dashboardNavigation: NavigationItem[] = [
label: '规则',
icon: 'proxy',
},
{
href: '/pages',
label: 'Pages',
icon: 'pages',
},
{
href: '/website',
label: '网站',
@@ -18,6 +18,7 @@ function buildNode(overrides: Partial<NodeItem> = {}): NodeItem {
relay_client_proxy_url: '',
relay_auth_token: '',
relay_status: 'healthy',
relay_web_server_enabled: false,
relay_frps_connections: 0,
relay_frps_proxy_count: 0,
geo_name: '',
+1
View File
@@ -5,6 +5,7 @@ export type NavigationIconKey =
| 'origin'
| 'domain'
| 'certificate'
| 'pages'
| 'proxy'
| 'waf'
| 'release'
+7 -2
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
@@ -8,11 +9,14 @@ WORKDIR /build
COPY openflared/go.mod openflared/go.sum ./
COPY openflare_server/go.mod openflare_server/go.sum ../openflare_server/
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY openflare_server/ ../openflare_server/
COPY openflared/ .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-flared/internal/config.Version=$VERSION'" -o flared ./cmd/flared
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-flared/internal/config.Version=$VERSION'" -o flared ./cmd/flared
# Final runtime image
FROM fatedier/frpc:v0.69.0
@@ -27,3 +31,4 @@ ENV OPENFLARE_FRPC_PATH=/usr/bin/frpc
ENTRYPOINT ["/app/flared"]
CMD []
+13 -10
View File
@@ -1,13 +1,10 @@
module openflare-flared
go 1.25.0
go 1.25.7
replace openflare => ../openflare_server
require (
golang.org/x/net v0.55.0
openflare v0.0.0-00010101000000-000000000000
)
require openflare v0.0.0-00010101000000-000000000000
require (
github.com/bwmarrin/snowflake v0.3.0 // indirect
@@ -35,7 +32,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -44,18 +41,24 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pressly/goose/v3 v3.27.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
@@ -65,8 +68,8 @@ require (
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.25.10 // 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
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+48 -15
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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=
@@ -55,8 +57,8 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
github.com/go-playground/validator/v10 v10.23.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-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@@ -64,16 +66,18 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -98,6 +102,8 @@ github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUK
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -106,6 +112,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
@@ -119,11 +127,14 @@ github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
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=
@@ -139,6 +150,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
@@ -183,13 +196,33 @@ gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
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=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+1
View File
@@ -31,6 +31,7 @@ func (r *Runner) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
r.FrpcManager.Stop()
return ctx.Err()
default:
}
+52 -2
View File
@@ -129,6 +129,8 @@ func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTun
if _, ok := activeRelays[relayID]; !ok {
slog.Info("stopping obsolete frpc process", "relay_id", relayID)
proc.Cancel()
pidPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.pid", relayID))
_ = os.Remove(pidPath)
delete(m.processes, relayID)
}
}
@@ -142,11 +144,16 @@ func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTun
}
func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath string) {
pidPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.pid", relayID))
if proc, ok := m.processes[relayID]; ok {
proc.Cancel()
_ = os.Remove(pidPath)
}
procCtx, cancel := context.WithCancel(context.Background())
if ctx == nil {
ctx = context.Background()
}
procCtx, cancel := context.WithCancel(ctx)
proc := &Process{
RelayID: relayID,
Cancel: cancel,
@@ -167,6 +174,8 @@ func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath
}
m.mu.Unlock()
ensureNoOrphanProcess(pidPath)
cmd := exec.CommandContext(procCtx, m.cfg.FrpcPath, "-c", configPath)
m.mu.Lock()
@@ -175,7 +184,12 @@ func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath
m.mu.Unlock()
startedAt := time.Now()
err := cmd.Run()
err := cmd.Start()
if err == nil {
_ = os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0o644)
err = cmd.Wait()
}
_ = os.Remove(pidPath)
m.mu.Lock()
if procCtx.Err() != nil {
@@ -212,6 +226,20 @@ func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath
}()
}
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
for relayID, proc := range m.processes {
if proc != nil && proc.Cancel != nil {
proc.Cancel()
}
pidPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.pid", relayID))
_ = os.Remove(pidPath)
delete(m.processes, relayID)
}
}
func buildFrpcToml(relay service.FlaredRelayInfo, proxies []service.FlaredProxyEntry) string {
var buf bytes.Buffer
@@ -298,3 +326,25 @@ func (m *Manager) LoadState() error {
m.mu.Unlock()
return nil
}
func ensureNoOrphanProcess(pidPath string) {
data, err := os.ReadFile(pidPath)
if err != nil {
return
}
var pid int
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
return
}
if pid <= 0 {
return
}
process, err := os.FindProcess(pid)
if err == nil && process != nil {
slog.Warn("attempting to kill potentially orphan process", "pid", pid, "pid_path", pidPath)
_ = process.Kill()
// Wait a little bit to ensure the OS has reclaimed ports
time.Sleep(500 * time.Millisecond)
}
_ = os.Remove(pidPath)
}
+120
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -76,6 +77,21 @@ func assertStatusEventually(t *testing.T, m *Manager, relayID string, expectedSt
t.Fatalf("expected status eventually %s, got %s (err: %s)", expectedStatus, got, errStr)
}
func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
t.Helper()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-time.After(timeout):
t.Fatalf("expected process pid=%d to exit within %s", cmd.Process.Pid, timeout)
case <-done:
}
}
func TestStartProcessSuccess(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 5) // exit code 0, sleep 5s
@@ -265,3 +281,107 @@ func TestBackoffReset(t *testing.T) {
m.mu.RUnlock()
proc.Cancel()
}
func TestUpdateConfigKillsOrphanProcessBeforeRestart(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 5)
cfg := &config.Config{
ServerURL: "http://localhost:8080",
TunnelToken: "test-token",
FrpcPath: scriptPath,
DataDir: dir,
StatePath: filepath.Join(dir, "flared-state.json"),
}
m := NewManager(cfg)
orphan := exec.Command("sh", "-c", "sleep 30")
if err := orphan.Start(); err != nil {
t.Fatalf("failed to start orphan process: %v", err)
}
t.Cleanup(func() {
if orphan.Process != nil {
_ = orphan.Process.Kill()
}
})
pidPath := filepath.Join(dir, "frpc_relay-1.pid")
if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", orphan.Process.Pid)), 0o644); err != nil {
t.Fatalf("failed to seed orphan pid file: %v", err)
}
newConfig := &service.FlaredTunnelConfigResponse{
Version: "1",
Checksum: "sum1",
Relays: []service.FlaredRelayInfo{
{
RelayNodeID: "relay-1",
Address: "127.0.0.1:7000",
AuthToken: "auth-1",
},
},
}
if err := m.UpdateConfig(context.Background(), newConfig); err != nil {
t.Fatalf("failed to UpdateConfig: %v", err)
}
assertCommandExitedEventually(t, orphan, 2*time.Second)
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
m.mu.RLock()
proc := m.processes["relay-1"]
m.mu.RUnlock()
proc.Cancel()
}
func TestStopCancelsRunningProcesses(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 30)
cfg := &config.Config{
ServerURL: "http://localhost:8080",
TunnelToken: "test-token",
FrpcPath: scriptPath,
DataDir: dir,
StatePath: filepath.Join(dir, "flared-state.json"),
}
m := NewManager(cfg)
newConfig := &service.FlaredTunnelConfigResponse{
Version: "1",
Checksum: "sum1",
Relays: []service.FlaredRelayInfo{
{
RelayNodeID: "relay-1",
Address: "127.0.0.1:7000",
AuthToken: "auth-1",
},
},
}
if err := m.UpdateConfig(context.Background(), newConfig); err != nil {
t.Fatalf("failed to UpdateConfig: %v", err)
}
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
m.mu.RLock()
proc := m.processes["relay-1"]
if proc == nil || proc.Cmd == nil {
m.mu.RUnlock()
t.Fatal("expected running process to have a command handle")
}
cmd := proc.Cmd
m.mu.RUnlock()
m.Stop()
assertCommandExitedEventually(t, cmd, 2*time.Second)
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.processes) != 0 {
t.Fatalf("expected no managed processes after stop, got %d", len(m.processes))
}
}