mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 13:46:38 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9da1ce8456 | |||
| 9f9cbd4ede | |||
| da1409fdac | |||
| 174198c283 | |||
| 796bf1c22f | |||
| 80dd5f8b31 | |||
| 14d41ad807 | |||
| fe2414ead5 | |||
| 649287a775 | |||
| 2514e7edc4 | |||
| 7ab11154e3 | |||
| 97c10b8d0b | |||
| ceae693a20 | |||
| edb356f40e | |||
| 81ba309650 | |||
| 5612403d48 | |||
| 4775e5cb73 | |||
| bc3d9ee285 | |||
| e654441127 | |||
| a987c0d681 | |||
| a85919fd9e | |||
| 4cb8928e4e | |||
| 57616626fd | |||
| 449d0a5c5b | |||
| 1c89db8ffa | |||
| 46f49cc349 | |||
| f365b3d331 | |||
| 4ae6c2718f | |||
| 8894620b92 | |||
| c2fcd2eddf | |||
| ec70794577 | |||
| bcd669722e | |||
| 9975ac90c4 | |||
| 632c455229 | |||
| b60cde02ac | |||
| 21ed214ba9 |
@@ -2,11 +2,6 @@ name: Cleanup prerelease tags
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: "Type cleanup-prerelease-tags to delete all prerelease releases and tags"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -40,11 +35,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CONFIRM: ${{ github.event.inputs.confirm }}
|
||||
run: |
|
||||
if [[ "$CONFIRM" != "cleanup-prerelease-tags" ]]; then
|
||||
echo "confirm input must be exactly cleanup-prerelease-tags" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
mapfile -t TAGS < <(git tag --list 'v*' | sort -V)
|
||||
DELETED=0
|
||||
|
||||
|
||||
@@ -1,51 +1,87 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,先按顺序阅读以下 VitePress 文档源文件:
|
||||
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,请根据以下分层文档指引进行阅读与开发:
|
||||
|
||||
1. [docs/design/index.md](./docs/design/index.md)
|
||||
作用:理解当前 MVP 的产品范围、系统边界、核心对象和长期约束。
|
||||
## 1. 核心必读文档(Level 3 & Level 4)- 必须阅读 ⚠️
|
||||
|
||||
2. [docs/design/architecture.md](./docs/design/architecture.md)
|
||||
作用:理解 Server、Agent、OpenResty 与前端的职责边界。
|
||||
为了理解 OpenFlare 的设计理念、产品边界、核心机制以及代码编写的工程约束,**AI 在接手项目时必须首先且完整阅读以下文档**:
|
||||
|
||||
3. [docs/design/release-model.md](./docs/design/release-model.md)
|
||||
作用:理解配置发布、激活、回滚与 Agent 应用模型。
|
||||
### Level 3: 面向贡献者的参阅文档 (Contributor References)
|
||||
* **[docs/design/index.md](./docs/design/index.md)**
|
||||
*作用:理解当前 MVP 的产品范围、系统边界、核心对象和长期约束。*
|
||||
* **[docs/design/architecture.md](./docs/design/architecture.md)**
|
||||
*作用:理解 Server、Agent、OpenResty 与前端的职责边界与网络拓扑。*
|
||||
* **[docs/design/release-model.md](./docs/design/release-model.md)**
|
||||
*作用:理解配置发布、激活、回滚与 Agent 节点配置应用的模型。*
|
||||
* **[docs/design/development.md](./docs/design/development.md)**
|
||||
*作用:了解如何搭建本地开发环境,运行后端 Server、Agent 和前端开发服务器,以及运行测试与构建的命令。*
|
||||
* **[docs/design/repository.md](./docs/design/repository.md)**
|
||||
*作用:熟悉仓库的整体物理结构和各子目录的职责。*
|
||||
|
||||
4. [docs/design/development.md](./docs/design/development.md)
|
||||
作用:理解当前开发规范、阶段原则、分层约束、数据模型边界、API 约定、Agent 约束、前端规范与测试要求。
|
||||
### Level 4: 面向 AI 的开发指导规范 (AI Guidelines)
|
||||
* **[docs/guildline/development-constraints.md](./docs/guildline/development-constraints.md)**
|
||||
*作用:掌握核心后端/Agent/前端分层约束、数据模型规范、数据库迁移升级协议、API 与鉴权设计准则。*
|
||||
* **[docs/guildline/Guidelines.md](./docs/guildline/Guidelines.md)**
|
||||
*作用:通用的 Go 后端开发与高质量编码准则,包括架构、并发、错误处理、安全及工作流程。*
|
||||
* **[docs/guildline/Project.md](./docs/guildline/Project.md)**
|
||||
*作用:针对 OpenFlare 后端特定的控制器参数解析、响应处理、纯净工具类与数据库逻辑完全隔离、Go 泛型切片去重及 JSON 序列化避坑细则。*
|
||||
|
||||
5. [docs/guide/deployment.md](./docs/guide/deployment.md)
|
||||
作用:理解当前部署方式、Agent 接入、升级、卸载和联调步骤。
|
||||
---
|
||||
|
||||
6. [docs/reference/configuration.md](./docs/reference/configuration.md)
|
||||
作用:理解系统启动时支持的环境变量、命令行参数、运行时配置项和 Agent 配置字段。
|
||||
## 2. 按需查阅文档(Level 2)- 根据需求阅读 💡
|
||||
|
||||
如任务涉及用户文档、贡献者入口或排障体验,还应阅读:
|
||||
当开发任务涉及具体的系统部署、升级、接口联调或配置字段查阅时,**AI 应当根据需求阅读相应的参考手册**:
|
||||
|
||||
* [docs/guide/quick-start.md](./docs/guide/quick-start.md):理解新用户从 0 到运行的最短路径。
|
||||
* [docs/guide/usage.md](./docs/guide/usage.md):理解网站配置、证书、发布、回滚和观测的基础用法。
|
||||
* [docs/guide/development.md](./docs/guide/development.md):理解本地开发、测试和构建命令。
|
||||
* [docs/guide/troubleshooting.md](./docs/guide/troubleshooting.md):理解常见失败症状与排查路径。
|
||||
### Level 2: 面对高级用户/开发者的参阅文档 (Reference Manuals)
|
||||
* **[docs/reference/configuration.md](./docs/reference/configuration.md)**
|
||||
*作用:系统启动时支持的所有环境变量、命令行参数、运行时 Option 选项和 Agent 配置文件字段。*
|
||||
* **[docs/reference/cli.md](./docs/reference/cli.md)**
|
||||
*作用:Server 与 Agent 可用的命令行参数、安装/卸载脚本参数等参考。*
|
||||
* **[docs/reference/api.md](./docs/reference/api.md)**
|
||||
*作用:管理端 API 与 Agent API 的响应结构、路径和详细鉴权约定。*
|
||||
* **[docs/reference/deployment.md](./docs/reference/deployment.md)**
|
||||
*作用:理解 Server 和 Agent 的单机、Docker 部署配置,以及 Agent 接入、升级、卸载和联调步骤。*
|
||||
* **[docs/reference/server.md](./docs/reference/server.md)**
|
||||
*作用:如何配置系统配置、服务环境变量并正确启动 Server 服务。*
|
||||
* **[docs/reference/agent.md](./docs/reference/agent.md)**
|
||||
*作用:理解 Agent 接入的 discovery/agent 令牌鉴权机制、本地配置文件及 Docker 部署参数。*
|
||||
* **[docs/reference/upgrade.md](./docs/reference/upgrade.md)**
|
||||
*作用:Server 及各代理节点 Agent 的升级步骤与维护策略。*
|
||||
|
||||
线上文档入口:https://open-flare.pages.dev
|
||||
---
|
||||
|
||||
## 3. 新手与业务教程(Level 1)- 体验与排障参考 📘
|
||||
|
||||
如果任务涉及优化最终用户体验、丰富业务能力或排查常见故障,可参阅面向普通用户的指南:
|
||||
|
||||
### Level 1: 面向新手用户的教程文档 (Novice Tutorials)
|
||||
* **[docs/guide/quick-start.md](./docs/guide/quick-start.md)**:五分钟内基于 Docker Compose 快速跑起 Server 和首个 Agent 节点的完整闭环。
|
||||
* **[docs/guide/usage.md](./docs/guide/usage.md)**:反向代理网站、源站、证书托管、配置发布与回滚的常规界面操作与观测功能使用指南。
|
||||
* **[docs/guide/sso.md](./docs/guide/sso.md)**:系统如何配置 GitHub OAuth 及标准 OIDC 第三方登录,以及绑定本地账户的流程。
|
||||
* **[docs/guide/first-site.md](./docs/guide/first-site.md)**:从零开始配置、发布并验证第一个代理网站的完整步骤。
|
||||
* **[docs/guide/troubleshooting.md](./docs/guide/troubleshooting.md)**:常见数据库迁移、节点离线、OpenResty 校验失败、SSL 证书失效等故障的表现症状及标准排障路径。
|
||||
|
||||
---
|
||||
|
||||
## 执行要求
|
||||
|
||||
* 如果实现内容超出 [产品边界](./docs/design/index.md),先修改设计文档,再继续编码。
|
||||
* 如果实现方式违反 [开发约束](./docs/design/development.md),应优先调整方案,而不是绕过规范。
|
||||
* 如果需求与当前阶段原则冲突,优先遵守 [开发约束](./docs/design/development.md) 中的变更准入与验收标准。
|
||||
* 如果任务涉及前端改造或管理端 UI,必须同时遵守 [开发约束](./docs/design/development.md) 中的前端规范。
|
||||
* 如果实现方式违反 [开发约束](./docs/guildline/development-constraints.md),应优先调整方案,而不是绕过规范。
|
||||
* 如果实现方式涉及后端代码逻辑,必须严格遵循 [docs/guildline/](./docs/guildline/) 下的所有开发准则。
|
||||
* 如果需求与当前阶段原则冲突,优先遵守 [开发约束](./docs/guildline/development-constraints.md) 中的变更准入与验收标准。
|
||||
* 如果任务涉及前端改造或管理端 UI,必须同时遵守 [开发约束](./docs/guildline/development-constraints.md) 中的前端规范。
|
||||
|
||||
## 文档维护要求
|
||||
|
||||
当以下内容发生变化时,应同步更新对应 VitePress 页面:
|
||||
当以下内容发生变化时,应同步更新对应中文文档,不要同步英文文档:
|
||||
|
||||
* 产品范围或系统边界变化:更新 `docs/design/index.md`
|
||||
* 系统结构、模块职责变化:更新 `docs/design/architecture.md`
|
||||
* 发布、同步、回滚模型变化:更新 `docs/design/release-model.md`
|
||||
* 开发约束、代码规范、接口约定、阶段原则、测试基线变化:更新 `docs/design/development.md`
|
||||
* 产品启动、部署、升级、联调方式变化:更新 `docs/guide/quick-start.md`、`docs/guide/deployment.md` 和 `README.md`
|
||||
* 业务分层、数据模型边界、接口约定、阶段原则、测试基线变化:更新 `docs/guildline/development-constraints.md`
|
||||
* 后端开发规范、代码质量要求、重构模式、去重逻辑与避坑指南变化:更新 `docs/guildline/` 下的对应开发准则文件
|
||||
* 产品启动、部署、升级、联调方式变化:更新 `docs/guide/quick-start.md`、`docs/reference/deployment.md` 和 `README.md`
|
||||
* 用户操作路径、常见场景变化:更新 `docs/guide/usage.md`
|
||||
* 本地开发、测试、构建方式变化:更新 `docs/guide/development.md`
|
||||
* 本地开发、测试、构建方式变化:更新 `docs/design/development.md`
|
||||
* 常见故障、排查路径变化:更新 `docs/guide/troubleshooting.md`
|
||||
* 环境变量、命令行参数、运行时配置、Agent 配置变化:更新 `docs/reference/configuration.md`
|
||||
|
||||
+8
-8
@@ -69,13 +69,8 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '概览', link: '' },
|
||||
{ text: '快速开始', link: 'quick-start' },
|
||||
{ text: '基础使用', link: 'usage' },
|
||||
{ text: '部署说明', link: 'deployment' },
|
||||
{ text: 'SSO 登录配置', link: 'sso' },
|
||||
{ text: '启动 Server', link: 'server' },
|
||||
{ text: '接入 Agent', link: 'agent' },
|
||||
{ text: '发布第一份配置', link: 'first-site' },
|
||||
{ text: '升级与维护', link: 'upgrade' },
|
||||
{ text: '本地开发', link: 'development' },
|
||||
{ text: '故障排查', link: 'troubleshooting' }
|
||||
]
|
||||
}
|
||||
@@ -88,10 +83,14 @@ function sidebarReference(): DefaultTheme.SidebarItem[] {
|
||||
text: '参考',
|
||||
items: [
|
||||
{ text: '概览', link: '' },
|
||||
{ text: '系统架构', link: '../design/architecture' },
|
||||
{ text: '启动 Server', link: 'server' },
|
||||
{ text: '接入 Agent', link: 'agent' },
|
||||
{ text: '部署说明', link: 'deployment' },
|
||||
{ text: '升级与维护', link: 'upgrade' },
|
||||
{ text: '配置项', link: 'configuration' },
|
||||
{ text: '命令与脚本', link: 'cli' },
|
||||
{ text: 'API 约定', link: 'api' },
|
||||
{ text: '仓库结构', link: 'repository' }
|
||||
{ text: 'API 约定', link: 'api' }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -105,7 +104,8 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '产品边界', link: '' },
|
||||
{ text: '系统架构', link: 'architecture' },
|
||||
{ text: '发布模型', link: 'release-model' },
|
||||
{ text: '开发约束', link: 'development' }
|
||||
{ text: '本地开发', link: 'development' },
|
||||
{ text: '仓库结构', link: 'repository' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+38
-19
@@ -26,12 +26,12 @@ Origin
|
||||
|
||||
## 组件职责
|
||||
|
||||
| 组件 | 职责 |
|
||||
| --- | --- |
|
||||
| Server | 管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询 |
|
||||
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
||||
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
|
||||
| Frontend | 管理网站配置、WAF、源站、证书、节点、版本、用户、设置与观测页面 |
|
||||
| 组件 | 职责 |
|
||||
| --------- | ---------------------------------------------------------------------- |
|
||||
| Server | 管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询 |
|
||||
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
||||
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
|
||||
| Frontend | 管理网站配置、WAF、源站、证书、节点、版本、用户、设置与观测页面 |
|
||||
|
||||
## Server
|
||||
|
||||
@@ -58,17 +58,24 @@ Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存
|
||||
|
||||
Agent 通过 `openresty_path` 指向的 OpenResty 二进制统一执行校验、reload、启动与重启;未配置时默认调用 `openresty`。Docker 部署时,Agent 镜像内置 OpenResty 二进制,仍走同一套二进制控制逻辑。
|
||||
|
||||
节点 IP 默认由 Agent 注册和心跳上报维护;如果管理端锁定节点 IP,Server 只更新运行状态、版本、观测等运行态字段,不再接受 Agent 上报覆盖该 IP。
|
||||
|
||||
## Frontend
|
||||
|
||||
`openflare_server/web` 是正式管理端前端:
|
||||
|
||||
* Next.js App Router。
|
||||
* Next.js 15 App Router。
|
||||
* React 19。
|
||||
* TypeScript。
|
||||
* Tailwind CSS。
|
||||
* TanStack Query 管理服务端状态。
|
||||
|
||||
前端静态导出后由 Go Server 托管。所有 API 请求应统一经过 `lib/api/`,并处理 `success/message/data` 响应结构。
|
||||
前端采用静态导出模式(`output: 'export'`),导出后由 Go Server 通过 `embed.FS` 托管。所有 API 请求应统一经过 `lib/api/`,并处理 `success/message/data` 响应结构。
|
||||
|
||||
Server 集成以下安全特性:
|
||||
* CORS 中间件:跨域请求保护。
|
||||
* 速率限制:全局与关键接口限流。
|
||||
* 会话管理:基于 Cookie/Redis 的会话存储。
|
||||
|
||||
## 数据与请求流
|
||||
|
||||
@@ -83,14 +90,23 @@ Browser -> Frontend -> /api/* -> controller -> service -> model -> database
|
||||
### Agent 同步流
|
||||
|
||||
```text
|
||||
Agent heartbeat -> Server 返回激活版本摘要
|
||||
Agent HTTP heartbeat -> Server 返回激活版本摘要
|
||||
Agent 发现新版本 -> 拉取配置详情
|
||||
Agent 写入主配置 / 路由配置 / 证书 / Lua 资源 / WAF 运行时配置
|
||||
Agent 执行 OpenResty 校验与 reload
|
||||
Agent 上报应用结果
|
||||
```
|
||||
|
||||
默认启用 WS 连接升级时,Agent 会先通过 HTTP heartbeat 获取设置,随后尝试连接 Agent WebSocket。WS 成功后,周期性状态上报改由 WS 承载;Server 发布或激活版本后会向已连接 Agent 广播激活版本摘要,使 Agent 立即进入既有同步流程。WS 断开或建立失败时,Agent 自动退回 HTTP heartbeat。
|
||||
**WebSocket 升级流程**(可选,通过 `AgentWebsocketUpgradeEnabled` 选项控制):
|
||||
|
||||
当启用 WebSocket 升级时:
|
||||
1. Agent 通过 HTTP heartbeat 获取运行配置与设置。
|
||||
2. Agent 尝试升级连接到 `GET /api/agent/ws`(WebSocket)。
|
||||
3. WS 连接成功后,周期性状态上报和实时消息由 WebSocket 承载,降低延迟。
|
||||
4. Server 发布或激活版本后,可向已连接 Agent 立即广播激活版本摘要,使 Agent 立即进入同步流程。
|
||||
5. 若 WebSocket 断开或建立失败,Agent 自动降级回 HTTP heartbeat,保证可用性。
|
||||
|
||||
通过 `OpenRestyWebsocketEnabled` 选项,可在 OpenResty 层面启用或禁用 WebSocket 反向代理支持。
|
||||
|
||||
### 反向代理流
|
||||
|
||||
@@ -123,16 +139,19 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
|
||||
* `node_health_events`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
* `acme_accounts`
|
||||
* `dns_accounts`
|
||||
* `geoip_update_configs`
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
| --- | --- |
|
||||
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界 |
|
||||
| Agent 主动拉取 | Server 不需要 SSH 权限,也不暴露远程命令入口 |
|
||||
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致 |
|
||||
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
|
||||
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
|
||||
| 决策 | 原因 |
|
||||
| ------------------------------ | --------------------------------------------------------------------------- |
|
||||
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界 |
|
||||
| Agent 主动拉取 | Server 不需要 SSH 权限,也不暴露远程命令入口;支持 HTTP 与 WebSocket 双协议 |
|
||||
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致;支持版本预览、历史查询与一键回滚 |
|
||||
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
|
||||
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
|
||||
|
||||
## 贡献者阅读建议
|
||||
|
||||
@@ -140,5 +159,5 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
|
||||
|
||||
1. [产品边界](./index.md)
|
||||
2. [发布模型](./release-model.md)
|
||||
3. [开发约束](./development.md)
|
||||
4. [仓库结构](../reference/repository.md)
|
||||
3. [开发约束](../guildline/development-constraints.md)
|
||||
4. [仓库结构](./repository.md)
|
||||
|
||||
+125
-246
@@ -1,303 +1,182 @@
|
||||
# 开发约束
|
||||
# 本地开发
|
||||
|
||||
你会学到:OpenFlare 代码修改的准入标准、后端/Agent/前端分层约束、数据模型边界、API 约定、数据库迁移要求和测试交付基线。
|
||||
你会学到:如何搭建 OpenFlare 的本地开发环境、启动 Server、Agent 和管理端前端,运行测试与构建命令,并理解贡献代码前需要遵守的边界。
|
||||
|
||||
本文档融合原开发规范、前端规范与开发计划,是 OpenFlare `1.0.0` 之后的工程约束入口。
|
||||
本页面向贡献者。产品边界、数据模型约束、API 约定和前端分层规范以 [开发约束](../guildline/development-constraints.md) 为准;本页只提供可执行的本地开发流程。
|
||||
|
||||
## 当前结论
|
||||
## 仓库结构
|
||||
|
||||
* 第一版至第六版的主线能力已经全部完成。
|
||||
* `1.0.0` 是当前正式基线。
|
||||
* 已完成阶段的过程性任务以代码、测试与 Git 历史为准。
|
||||
* 新工作优先以缺陷修复、可维护性改进、文档与测试补强为主。
|
||||
项目的核心物理目录及各模块(Server、Agent、Frontend 等)的职责分层,详见 [仓库结构](./repository.md)。
|
||||
|
||||
当前开发优先级:
|
||||
## 环境要求
|
||||
|
||||
1. 稳定性。
|
||||
2. 升级与回滚链路可靠性。
|
||||
3. 文档准确性。
|
||||
4. 测试覆盖补强。
|
||||
5. 在既有边界内的小步迭代。
|
||||
|
||||
## 变更准入
|
||||
|
||||
新需求进入实现前,按以下顺序判断:
|
||||
|
||||
1. 是否符合 [产品边界](./)。
|
||||
2. 是否符合本文档的后端、Agent 与前端约束。
|
||||
3. 是否会破坏现有发布、同步、回滚或升级主链路。
|
||||
4. 是否需要同步更新部署、配置、README 或文档站页面。
|
||||
|
||||
如果需求超出边界或引入新基础设施,应先更新设计文档,再开始实现。
|
||||
|
||||
任何合入正式基线的改动,至少应满足:
|
||||
|
||||
* 不破坏 Agent 心跳、同步、发布与回滚主链路。
|
||||
* 不破坏现有 OpenResty 主配置托管模型。
|
||||
* 不降低总览、节点详情与访问分析的既有可用性。
|
||||
* 有与风险相称的测试或联调验证。
|
||||
* 文档与代码保持一致。
|
||||
|
||||
## 技术基线
|
||||
|
||||
Server:
|
||||
|
||||
* Go 1.25+
|
||||
* Gin
|
||||
* GORM
|
||||
* SQLite / PostgreSQL
|
||||
* 现有登录体系
|
||||
|
||||
Agent:
|
||||
|
||||
* 单二进制
|
||||
* 节点本地执行
|
||||
* 通过 `openresty_path` 或默认 `openresty` 控制 OpenResty 二进制
|
||||
* Docker 部署使用内置 OpenResty 的 Agent 镜像,不由 Agent 再控制独立 OpenResty 容器
|
||||
|
||||
Frontend:
|
||||
|
||||
* Next.js 15 App Router
|
||||
* React 19
|
||||
* TypeScript 5
|
||||
* Tailwind CSS 4
|
||||
* TanStack Query
|
||||
* React Hook Form + Zod
|
||||
* Zustand 仅用于轻量客户端状态
|
||||
* ESLint + Prettier
|
||||
* Vitest + Testing Library + Playwright
|
||||
* pnpm
|
||||
|
||||
## Server 分层
|
||||
|
||||
| 目录 | 职责 |
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| `controller/` | 参数解析、调用 service、返回响应 |
|
||||
| `service/` | 业务逻辑、校验、事务编排、渲染 |
|
||||
| `model/` | 模型定义与持久化 |
|
||||
| `router/` | 路由注册 |
|
||||
| `middleware/` | 认证、鉴权、限流等横切逻辑 |
|
||||
| `common/` | 配置、全局状态与初始化入口 |
|
||||
| `utils/` | 纯工具函数与通用 helper |
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| pnpm | 推荐通过 `corepack enable` 使用项目声明版本 |
|
||||
| Docker | Server 容器、本地联调和 Agent Docker 镜像需要 |
|
||||
| OpenResty | 本地运行 Agent 时需要可执行 `openresty` |
|
||||
| PostgreSQL | 可选;未配置时 Server 使用 SQLite |
|
||||
|
||||
禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
|
||||
## 初始化前端依赖
|
||||
|
||||
## Agent 分层
|
||||
|
||||
Agent 保持现有模块边界:
|
||||
|
||||
* `config`
|
||||
* `heartbeat`
|
||||
* `sync`
|
||||
* `openresty` / `nginx`
|
||||
* `state`
|
||||
* `httpclient`
|
||||
* `protocol`
|
||||
* `internal/updater`
|
||||
|
||||
要求:
|
||||
|
||||
* 每个模块职责单一。
|
||||
* 外部命令调用集中封装。
|
||||
* 状态落盘与配置落盘分离。
|
||||
|
||||
## Frontend 分层
|
||||
|
||||
推荐目录:
|
||||
|
||||
```text
|
||||
app/
|
||||
components/
|
||||
features/
|
||||
lib/
|
||||
hooks/
|
||||
store/
|
||||
types/
|
||||
styles/
|
||||
tests/
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
```
|
||||
|
||||
职责约束:
|
||||
构建供 Go Server 托管的静态产物:
|
||||
|
||||
* `app/`:路由、布局、页面组装。
|
||||
* `features/`:按业务域组织模块。
|
||||
* `components/`:跨 feature 复用组件。
|
||||
* `lib/`:请求客户端、环境变量、工具函数、常量。
|
||||
* `store/`:少量跨页面 UI 状态。
|
||||
* `types/`:共享类型定义。
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
页面文件只负责获取路由参数、组织页面结构、调用 feature 组件;不应手写复杂 API 细节、复杂表单校验逻辑或维护大量彼此耦合的局部状态。
|
||||
## 启动 Server
|
||||
|
||||
## 数据模型规范
|
||||
SQLite 模式:
|
||||
|
||||
当前有效实体:
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `options`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
PostgreSQL 模式:
|
||||
|
||||
通用约束:
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
* 不新增平台化对象,除非设计文档明确要求。
|
||||
* `origins` 仅作为可复用源站地址目录,字段保持轻量。
|
||||
* `proxy_routes` 以“网站配置”作为聚合边界,必须包含唯一 `site_name` 与非空 `domains` 列表。
|
||||
* `proxy_routes.domains` 中的每个域名都必须全局唯一,列表第一项视为主域名。
|
||||
* `proxy_routes` 继续允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`。
|
||||
* 遗留 `domain` 字段只能作为 `domains[0]` 的兼容镜像;新代码不得继续以该字段作为唯一业务输入。
|
||||
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`。
|
||||
* 上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`。
|
||||
* 流量限制、反向代理与缓存配置当前都归属站点级 `proxy_routes`。
|
||||
* HTTPS 证书绑定必须通过与 `domains` 平行的 `domain_cert_ids` 逐域名保存;未绑定证书的域名不得参与 HTTPS 渲染。
|
||||
* WAF 全局规则组默认应用到所有网站,自定义规则组通过 `waf_rule_group_bindings` 绑定到网站配置;发布时必须进入完整版本快照。
|
||||
* `config_versions` 必须保存完整快照与渲染结果。
|
||||
* 全局同时只能有一个激活版本。
|
||||
* 回滚通过重新激活旧版本实现。
|
||||
* `nodes` 只保留控制面状态与低频摘要。
|
||||
* 观测数据必须按节点与时间窗口关联,快照与聚合结果采用追加式模型。
|
||||
* 原始访问明细必须有受控保留策略。
|
||||
* `auth_sources` 仅保存管理端第三方登录源配置,当前支持 `github` 与 `oidc`。
|
||||
* `external_accounts` 是第三方账号与本地用户的唯一绑定来源;旧 `users.github_id` 仅用于兼容迁移,不得作为新登录流程的业务输入。
|
||||
默认访问地址:
|
||||
|
||||
## 数据库迁移
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号。
|
||||
默认账号是 `root` / `123456`。
|
||||
|
||||
数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库。
|
||||
## 启动前端开发服务器
|
||||
|
||||
每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法。迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录。
|
||||
前端开发服务器默认监听 `3001`,并通过 `NEXT_DEV_BACKEND_URL` 代理到后端:
|
||||
|
||||
新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本。
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
export NEXT_DEV_BACKEND_URL='http://127.0.0.1:3000'
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本。
|
||||
访问:
|
||||
|
||||
如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录。涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试。
|
||||
```text
|
||||
http://localhost:3001
|
||||
```
|
||||
|
||||
## API 与鉴权
|
||||
## 启动 Agent
|
||||
|
||||
管理端与 Agent API 统一使用 JSON。成功与失败都必须返回清晰 `message`:
|
||||
创建本地 `agent.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": {}
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
约定:
|
||||
运行:
|
||||
|
||||
* Agent API 固定放在 `/api/agent/*`。
|
||||
* 总览与节点详情优先使用专用聚合接口。
|
||||
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
|
||||
* 管理端继续复用现有登录、角色与 Session。
|
||||
* 第三方登录统一通过认证源 API 进入,认证源管理接口必须要求 Root Session。
|
||||
* `/api/status` 只能返回已启用认证源的公开字段,不得返回 Client Secret。
|
||||
* 第三方账号未绑定且注册关闭时,应提供绑定已有账号流程,不得自动创建用户。
|
||||
* Agent 正式请求统一使用节点专属 `agent_token`。
|
||||
* 首次接入可使用全局 `discovery_token`。
|
||||
* Agent 请求头统一使用 `X-Agent-Token`。
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='debug'
|
||||
go run ./cmd/agent -config ./agent.json
|
||||
```
|
||||
|
||||
禁止暴露远程 shell 或任意命令执行入口,禁止在日志中打印完整 Token,禁止绕过占位符约束保存不可渲染的主配置模板。
|
||||
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。调试时可显式配置 `openresty_path`、`main_config_path`、`route_config_path`、`access_log_path`、`cert_dir`、`lua_dir` 和 `runtime_config_dir`。
|
||||
|
||||
## 发布与运行
|
||||
## 测试
|
||||
|
||||
发布逻辑必须保持:
|
||||
Server:
|
||||
|
||||
* 发布时读取全部启用的 `proxy_routes`。
|
||||
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。
|
||||
* 生成完整 OpenResty 配置。
|
||||
* 计算 `checksum`。
|
||||
* 写入 `config_versions`。
|
||||
* 通过切换 `is_active` 激活版本。
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
版本约束:
|
||||
Agent:
|
||||
|
||||
* 版本号格式固定为 `YYYYMMDD-NNN`。
|
||||
* 不在线修改历史版本。
|
||||
* 不做按节点分组的差异化版本。
|
||||
* 预览与 diff 是只读能力,不产生发布记录。
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Agent 必须满足:
|
||||
Frontend:
|
||||
|
||||
* 启动后读取或生成本地 `node_id`。
|
||||
* 周期性心跳与同步。
|
||||
* 常规同步优先依据 heartbeat 返回的版本摘要判断。
|
||||
* WS 连接升级开启且连接成功时,Agent 可通过 WS 接收激活版本摘要并立即同步;WS 失败或断开必须退回 HTTP heartbeat。
|
||||
* 发现新版本时先备份旧文件。
|
||||
* 写入主配置、路由配置与必要证书文件。
|
||||
* 写入 WAF/PoW 运行时配置,并确保 WAF Lua 资源由 Agent 统一管理。
|
||||
* 写入新配置后执行 `openresty -t -c <main_config_path>`,再 reload;reload 发现运行时未启动时允许直接启动 OpenResty。
|
||||
* 周期性运行时健康检查不得调用 `openresty -t`,避免健康探针触发 upstream 域名同步解析;应优先请求本地 `openresty_observability_port` 上的 `/openflare/stub_status`,以 HTTP `200 OK` 作为 OpenResty 主进程和 worker 正在提供服务的判断依据。
|
||||
* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty。
|
||||
* 回滚后 OpenResty 恢复正常时上报警告;如果本地没有历史主配置可恢复,必须允许写入内置安全兜底配置并拉起对外只监听 `80` 端口、统一返回 `503` 的 OpenResty 运行态;兜底配置仍需保留本地 `stub_status` 健康检查入口。
|
||||
* 兜底运行态不得清除失败目标的阻断状态;应用记录必须能体现目标版本失败但 fallback runtime 已启动。存在历史主配置但回滚后仍无法恢复运行时上报失败。
|
||||
* 某个目标 `version + checksum` 一旦应用失败并回退,Agent 必须在本地状态中阻断该目标的重复应用。
|
||||
* Agent 维护本地 MaxMind mmdb 时,下载或刷新失败只能记录警告,不得阻断心跳、同步、配置应用或 OpenResty 健康检查。
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
## 前端请求、状态与类型
|
||||
Docs:
|
||||
|
||||
所有 API 请求必须统一经过 `lib/api/`:
|
||||
```bash
|
||||
cd docs
|
||||
pnpm build
|
||||
```
|
||||
|
||||
* 统一处理 `success/message/data` 响应结构。
|
||||
* 统一处理鉴权失效、网络异常和通用错误消息。
|
||||
* 统一维护资源接口与请求路径。
|
||||
## 构建
|
||||
|
||||
状态分层:
|
||||
管理端静态产物:
|
||||
|
||||
* 服务端状态:TanStack Query。
|
||||
* 页面临时状态:组件内部 `useState`。
|
||||
* 跨页面 UI 状态:Zustand。
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
要求开启 TypeScript 严格模式,禁止滥用 `any`,API 响应、表单输入、业务实体必须有明确类型。
|
||||
Server 二进制:
|
||||
|
||||
## 表单、交互、样式与主题
|
||||
```bash
|
||||
cd openflare_server
|
||||
go build -o openflare-server .
|
||||
```
|
||||
|
||||
表单统一使用 React Hook Form 与 Zod。
|
||||
Agent 二进制:
|
||||
|
||||
高风险操作必须二次确认、展示操作对象名称,并明确成功与失败反馈。
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
```
|
||||
|
||||
样式原则:
|
||||
## 调试入口
|
||||
|
||||
* 统一使用 Tailwind CSS 与现有 token 体系。
|
||||
* 优先复用已有基础组件与布局组件。
|
||||
* 保持视觉层级、留白与语义颜色一致。
|
||||
| 场景 | 命令或位置 |
|
||||
| --- | --- |
|
||||
| Server 日志 | `LOG_LEVEL=debug go run .` |
|
||||
| Agent 日志 | `LOG_LEVEL=debug go run ./cmd/agent -config ./agent.json` |
|
||||
| Swagger | `http://localhost:3000/swagger/index.html` |
|
||||
| 前端 API 代理 | `NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev` |
|
||||
| OpenResty 配置校验 | `openresty -t -c ./data/etc/nginx/nginx.conf` |
|
||||
|
||||
主题要求:
|
||||
## 代码风格与变更准入
|
||||
|
||||
* 同时支持 `light`、`dark`、`system`。
|
||||
* 用户选择必须持久化。
|
||||
* 首屏尽量避免主题闪烁。
|
||||
贡献前先确认:
|
||||
|
||||
## 测试与交付
|
||||
1. 需求符合 [产品边界](./index.md)。
|
||||
2. 实现符合 [开发约束](../guildline/development-constraints.md)。
|
||||
3. 不破坏发布、同步、回滚或升级主链路。
|
||||
4. 涉及配置、部署、API 或产品边界时同步更新文档。
|
||||
5. 风险较高的修改补充测试或等效联调验证。
|
||||
|
||||
* 关键业务逻辑必须有单元测试或等效回归测试。
|
||||
* Agent 主链路修改必须验证同步、应用与回滚。
|
||||
* 前端页面至少覆盖加载态、空态、错误态与成功反馈。
|
||||
* Go 版本调整时,同步检查 `go.mod`、Dockerfile 与 CI 工作流。
|
||||
|
||||
## 后续维护方式
|
||||
|
||||
后续规划不再按“大版本阶段文档”维护,而采用以下方式:
|
||||
|
||||
* 产品边界变动:更新 [产品边界](./)。
|
||||
* 工程约束变动:更新本文档。
|
||||
* 部署与配置变动:更新 [部署说明](../guide/deployment.md)、[配置项](../reference/configuration.md) 与 README。
|
||||
|
||||
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文档。
|
||||
|
||||
当前专项“网站级规则与配置界面改造”的模型边界已纳入 [产品边界](./),执行时仍按数据模型、接口、前端页面、迁移测试与文档联动的顺序推进。
|
||||
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
|
||||
|
||||
+3
-33
@@ -15,16 +15,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队:
|
||||
|
||||
OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingress Controller 或多租户云平台。
|
||||
|
||||
## 目标用户
|
||||
|
||||
| 用户 | 需求 |
|
||||
| --- | --- |
|
||||
| 自托管用户 | 快速部署一个可视化 OpenResty 控制面 |
|
||||
| 内部运维团队 | 管理多个反向代理节点、证书和配置版本 |
|
||||
| 开发团队 | 为内部服务提供统一入口和基础访问分析 |
|
||||
| 贡献者 | 在明确边界内修复缺陷、补强测试和改进文档 |
|
||||
|
||||
## 当前稳定能力
|
||||
## 当前能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
@@ -58,27 +49,6 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
||||
| 证书托管 | 为不同域名绑定 TLS 证书 |
|
||||
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
|
||||
|
||||
## 核心对象
|
||||
|
||||
当前有效实体:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
|
||||
## 网站配置约束
|
||||
|
||||
@@ -164,8 +134,8 @@ WAF 以规则组为配置边界。系统固定一个全局规则组,默认应
|
||||
* 产品范围或系统边界变化时更新本文档。
|
||||
* 系统结构或模块职责变化时更新 [系统架构](./architecture.md)。
|
||||
* 发布、同步、回滚模型变化时更新 [发布模型](./release-model.md)。
|
||||
* 开发约束、代码规范、接口约定变化时更新 [开发约束](./development.md)。
|
||||
* 部署方式变化时更新 [部署说明](../guide/deployment.md) 与 README。
|
||||
* 开发约束、代码规范、接口约定变化时更新 [开发约束](../guildline/development-constraints.md)。
|
||||
* 部署方式变化时更新 [部署说明](../reference/deployment.md) 与 README。
|
||||
* 配置项变化时更新 [配置项参考](../reference/configuration.md)。
|
||||
* 已完成阶段不再以“版本计划”形式回填。
|
||||
* 新阶段开始前,先补设计,再进入实现。
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 仓库结构
|
||||
|
||||
你会学到:OpenFlare 仓库中 Server、Agent、前端、脚本和文档目录分别负责什么,以及贡献代码时应把逻辑放到哪一层。
|
||||
|
||||
| 路径 | 职责 |
|
||||
| ---------------------- | ---------------------------------------------------- |
|
||||
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL 单体控制面 |
|
||||
| `openflare_server/web` | Next.js 15 App Router 管理端前端,由 Go Server 托管 |
|
||||
| `openflare_agent` | Go 单体 Agent,运行在节点侧 |
|
||||
| `scripts` | Agent 安装、卸载等辅助脚本 |
|
||||
| `docs` | VitePress 文档站、设计基线、开发规范、部署与配置文档 |
|
||||
| `docs/en` | 英文版文档 |
|
||||
|
||||
## Server 分层
|
||||
|
||||
| 目录 | 职责 |
|
||||
| ------------- | ------------------------------------------------ |
|
||||
| `controller/` | 参数解析、调用 service、返回响应 |
|
||||
| `service/` | 业务逻辑、校验、事务编排、配置渲染 |
|
||||
| `model/` | 模型定义、数据库版本与迁移 |
|
||||
| `router/` | 路由注册 |
|
||||
| `middleware/` | 认证、鉴权、限流、CORS、Turnstile 验证等横切逻辑 |
|
||||
| `common/` | 配置、全局状态与初始化入口 |
|
||||
| `utils/` | 纯工具函数与通用 helper |
|
||||
| `job/` | 定时任务(如 SSL 证书续期) |
|
||||
| `upload/` | 文件上传处理 |
|
||||
| `docs/` | API 文档(Swagger) |
|
||||
| `data/` | 静态数据(如 GeoIP 数据库) |
|
||||
|
||||
## Agent 模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
| ---------------- | -------------------------------------------- |
|
||||
| `config/` | 配置读取与默认值 |
|
||||
| `heartbeat/` | 心跳与版本摘要判断 |
|
||||
| `sync/` | 配置拉取与应用编排 |
|
||||
| `nginx/` | OpenResty 文件写入、校验、reload、启动与回滚 |
|
||||
| `state/` | 本地状态与观测补报缓冲 |
|
||||
| `httpclient/` | Server 通信 |
|
||||
| `wsclient/` | WebSocket 客户端通信 |
|
||||
| `protocol/` | Agent API 协议类型 |
|
||||
| `updater/` | Agent 自更新逻辑 |
|
||||
| `logging/` | 日志处理 |
|
||||
| `observability/` | 可观测性(指标、链路等) |
|
||||
| `geoipdata/` | GeoIP 数据处理 |
|
||||
| `geoipupdate/` | GeoIP 数据更新 |
|
||||
| `agent/` | 核心 Agent 逻辑与生命周期 |
|
||||
|
||||
## Frontend 分层
|
||||
|
||||
| 目录 | 职责 |
|
||||
| ------------- | -------------------------------------------- |
|
||||
| `app/` | Next.js App Router 路由、布局、页面组装 |
|
||||
| `features/` | 按业务域组织的功能模块 |
|
||||
| `components/` | 跨 feature 复用的 UI 组件 |
|
||||
| `lib/` | 请求客户端、环境变量、工具函数、常量 |
|
||||
| `store/` | 少量跨页面 UI 状态管理 |
|
||||
| `types/` | 共享类型定义 |
|
||||
| `styles/` | 全局样式 |
|
||||
| `tests/` | 前端单元测试与集成测试(Vitest、Playwright) |
|
||||
| `scripts/` | 构建和部署相关脚本 |
|
||||
| `public/` | 静态资源 |
|
||||
@@ -0,0 +1,188 @@
|
||||
You are a senior Go backend engineer responsible for maintaining and developing a long-evolving Go application.
|
||||
|
||||
Your goal is not to "write code as quickly as possible," but to produce high-quality code that is maintainable, testable, evolvable, and conforms to Go ecosystem practices. It is forbidden to pile up temporary code, over-abstract, duplicate logic, or break the existing architecture just to complete tasks.
|
||||
|
||||
Before any development, you must first read and understand the existing code structure, including:
|
||||
- Project directory structure
|
||||
- Entry files
|
||||
- Configuration management methods
|
||||
- Database/cache/message queue access methods
|
||||
- HTTP/RPC/API layer design
|
||||
- Layering methods such as service/usecase/domain/repository
|
||||
- Error handling methods
|
||||
- Logging methods
|
||||
- Test organization methods
|
||||
- Dependency injection methods
|
||||
- Existing coding style
|
||||
|
||||
If you are unsure of the responsibility of a certain module, infer it from the code context first; do not arbitrarily create duplicate modules.
|
||||
|
||||
Development Principles:
|
||||
|
||||
1. Architecture First
|
||||
- Prioritize integrating into the existing architecture rather than starting from scratch.
|
||||
- Do not arbitrarily add global variables, init side effects, or implicit dependencies.
|
||||
- Do not write business logic into handlers/controllers.
|
||||
- Handlers are only responsible for parameter parsing, authentication contexts, calling usecases/services, and returning responses.
|
||||
- Services/usecases are responsible for business orchestration.
|
||||
- Repositories/daos are responsible for data access.
|
||||
- Domain/models are responsible for core business objects and rules.
|
||||
- Isolate infrastructure code from business code.
|
||||
|
||||
2. Go Style
|
||||
- Use clear, direct, and simple Go code.
|
||||
- Do not mimic Java-style over-abstraction.
|
||||
- Interfaces should be defined by the consumer, not forced by the provider.
|
||||
- Prioritize small interfaces.
|
||||
- Naming must be accurate. Do not use vague names like Manager, Helper, or Util unless absolutely necessary.
|
||||
- Keep functions short and single-responsibility.
|
||||
- Do not introduce generics, reflection, or complex design patterns just to "look advanced."
|
||||
- Do not hide errors.
|
||||
- Errors must contain context information; use `fmt.Errorf("...: %w", err)` when necessary.
|
||||
- Do not panic, except for unrecoverable errors during the program startup phase.
|
||||
|
||||
3. Maintainability
|
||||
- Analyze the scope of impact before making modifications.
|
||||
- Keep changes minimal and avoid unrelated refactoring.
|
||||
- Do not change public APIs, database structures, or configuration formats unless explicitly requested by the task.
|
||||
- If changes must be made, explain the compatibility impact and migration plan.
|
||||
- Confirm there are no callers before deleting code.
|
||||
- Avoid copy-pasting existing logic; extract it to an appropriate place, but do not over-abstract.
|
||||
- Add necessary comments to complex business logic to explain "why", not the obvious "what".
|
||||
|
||||
4. Testing Requirements
|
||||
- New business logic must be supplemented with unit tests.
|
||||
- Bug fixes must be supplemented with regression tests.
|
||||
- Tests should cover normal paths, exceptional paths, and boundary conditions.
|
||||
- Do not break the structure of business code for testing convenience.
|
||||
- Isolate external dependencies using mocks/fakes/stubs.
|
||||
- Name tests clearly, e.g., TestXXX_WhenYYY_ShouldZZZ.
|
||||
- Prioritize table-driven tests, but do not sacrifice readability for table-driven structure.
|
||||
|
||||
5. Concurrency and Resource Management
|
||||
- Goroutines must have exit mechanisms.
|
||||
- Where context is involved, context.Context must be passed correctly.
|
||||
- Do not arbitrarily use context.Background() to replace upstream contexts.
|
||||
- Channels must have clear responsibility for closing.
|
||||
- Keep lock scopes small to avoid deadlocks.
|
||||
- Correctly close resources such as HTTP, databases, files, and connections.
|
||||
- Pay attention to race conditions, goroutine leaks, and connection leaks.
|
||||
|
||||
6. Database and Transactions
|
||||
- Database access must be in the repository/dao layer.
|
||||
- Transaction boundaries should be controlled by the business use case layer, rather than scattered across multiple lower-level functions.
|
||||
- Do not produce obviously inefficient N+1 queries in loops, unless the data volume is controllable and explained.
|
||||
- SQL must be readable and parameterized; unsanitized inputs are strictly forbidden from concatenation.
|
||||
- Schema changes must consider migration, rollback, and compatibility.
|
||||
|
||||
7. API Design
|
||||
- Request parameters must be validated.
|
||||
- Error responses must be stable and clear, without leaking internal sensitive information.
|
||||
- Do not print sensitive data such as passwords, tokens, keys, or ID numbers in logs.
|
||||
- Keep return structures backward-compatible.
|
||||
- HTTP status codes must be semantically correct.
|
||||
|
||||
8. Logging and Observability
|
||||
- Key paths must have necessary logs.
|
||||
- Error logs must contain the context needed for troubleshooting, but must not leak sensitive data.
|
||||
- Do not print logs excessively.
|
||||
- Do not use fmt.Println directly in library code.
|
||||
- If the project already has a logger, use the existing logger uniformly.
|
||||
|
||||
9. Security Requirements
|
||||
- All external inputs are untrusted.
|
||||
- Do not hardcode keys, tokens, or passwords.
|
||||
- Do not commit sensitive configurations to the repository.
|
||||
- Be mindful of injection risks in file paths, URLs, command executions, SQL, template rendering, etc.
|
||||
- Authentication and permission checks must be placed in clear locations, and must not rely on the self-discipline of the frontend or callers.
|
||||
|
||||
10. Performance Requirements
|
||||
- Do not optimize prematurely.
|
||||
- However, do not write obviously inefficient code.
|
||||
- Avoid unnecessary memory allocations, large object copies, and repeated parsing on hot paths.
|
||||
- Page, stream, or batch operations should be considered for large data processing.
|
||||
- If caching is introduced, the consistency, expiration strategy, and invalidation conditions must be explained.
|
||||
|
||||
Workflow:
|
||||
|
||||
Every time you receive a development task, you must follow these steps:
|
||||
|
||||
Step 1: Understand Requirements
|
||||
- Briefly rephrase the requirements in your own words.
|
||||
- Clarify inputs, outputs, boundary conditions, and exceptional cases.
|
||||
- If requirements are vague, list your reasonable assumptions; do not write code blindly.
|
||||
|
||||
Step 2: Read Existing Code
|
||||
- Identify relevant modules, call chains, data structures, interfaces, and tests.
|
||||
- Explain how the current code works.
|
||||
- Determine which layer the modification should be placed in.
|
||||
|
||||
Step 3: Design Solution
|
||||
- Provide a minimal viable modification plan.
|
||||
- Explain why it is placed in these files/modules.
|
||||
- State whether it affects existing APIs, databases, configurations, or tests.
|
||||
- If there are multiple solutions, compare their pros and cons and select the more stable one.
|
||||
|
||||
Step 4: Coding
|
||||
- Only modify code related to the task.
|
||||
- Maintain the existing code style.
|
||||
- Do not introduce unnecessary new dependencies.
|
||||
- Do not create duplicate logic.
|
||||
- Do not leave TODOs, temporary code, or debugging code.
|
||||
|
||||
Step 5: Testing
|
||||
- Supplement or update tests.
|
||||
- Explain what scenarios the tests cover.
|
||||
- If tests cannot be run, explain why and give the commands that should be run.
|
||||
|
||||
Step 6: Delivery Explanation
|
||||
- Summarize what was modified.
|
||||
- Explain why it was modified this way.
|
||||
- Explain potential risks.
|
||||
- Provide verification methods.
|
||||
- If there are incomplete items, they must be explicitly listed; do not pretend they are complete.
|
||||
|
||||
Output Format:
|
||||
|
||||
Each of your replies should contain:
|
||||
|
||||
1. Requirements Understanding
|
||||
2. Existing Code Analysis
|
||||
3. Modification Plan
|
||||
4. Specific Changes
|
||||
5. Testing and Verification
|
||||
6. Risks and Precautions
|
||||
|
||||
If you are only asked to review code, output:
|
||||
1. Problem List
|
||||
2. Severity: Critical / High / Medium / Low
|
||||
3. Impact Explanation
|
||||
4. Modification Suggestions
|
||||
5. Recommended Modification Example
|
||||
|
||||
Code Quality Red Lines:
|
||||
|
||||
The following behaviors are strictly prohibited:
|
||||
- Copying and pasting large blocks of duplicate code to complete requirements.
|
||||
- Stuffing business logic into handlers.
|
||||
- Passing `map[string]interface{}` everywhere.
|
||||
- Using global variables to bypass dependency injection.
|
||||
- Arbitrarily adding util/helper trash-can packages.
|
||||
- Ignoring errors.
|
||||
- Catch-all style error handling.
|
||||
- Continuing to stack logic when functions exceed reasonable length.
|
||||
- Modifying unrelated code.
|
||||
- Changing existing behavior without explanation.
|
||||
- Modifying core logic without tests.
|
||||
- Introducing large dependencies just to solve small problems.
|
||||
- Writing code without explaining the verification method.
|
||||
- Refactoring directly without understanding the existing architecture.
|
||||
|
||||
When you find that the existing code is already messy:
|
||||
- Do not perform a major refactoring all at once.
|
||||
- Stop the bleeding locally first.
|
||||
- Write new code within clear boundaries as much as possible.
|
||||
- Only make necessary changes to old code.
|
||||
- If refactoring is needed, propose a phased plan first.
|
||||
|
||||
Please always write code to the standards of "someone who will maintain this project for a long time", rather than "someone who completes a one-time task".
|
||||
+129
-16
@@ -1,33 +1,146 @@
|
||||
# Architecture
|
||||
# System Architecture
|
||||
|
||||
OpenFlare consists of Server, Agent, and local OpenResty on each node.
|
||||
You will learn: The overall architecture of OpenFlare, the responsibility boundaries of Server, Agent, OpenResty, and the management console frontend, and the request flow of a configuration release from the management console to take effect on a node.
|
||||
|
||||
OpenFlare consists of the Server, the Agent, local OpenResty on each node, and the management console frontend. The Server is the control plane, the Agent is the only controlled landing entry point on the node side, and OpenResty is the actual data plane.
|
||||
|
||||
```text
|
||||
OpenFlare Server (Gin + SQLite/PostgreSQL + Web UI)
|
||||
|
|
||||
| HTTP API / Config Pull
|
||||
v
|
||||
OpenFlare Agent (register / heartbeat / sync / apply / update)
|
||||
|
|
||||
v
|
||||
Browser
|
||||
|
|
||||
| Management UI / API
|
||||
v
|
||||
OpenFlare Server (Gin + GORM + SQLite/PostgreSQL)
|
||||
|
|
||||
| Agent API / heartbeat / config pull
|
||||
v
|
||||
OpenFlare Agent
|
||||
|
|
||||
| write config / openresty -t / reload / rollback
|
||||
v
|
||||
OpenResty binary
|
||||
|
|
||||
v
|
||||
|
|
||||
| reverse proxy
|
||||
v
|
||||
Origin
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| Server | Management UI, admin APIs, Agent APIs, configuration rendering, version publishing, data storage, and aggregate queries |
|
||||
| Agent | Registration, heartbeats, synchronization, writing files, configuration validation, reloads, fallback/rollbacks, self-updating, and lightweight data collection |
|
||||
| OpenResty | Receives real traffic, executes WAF, PoW, authentication, and reverse proxying according to configurations rendered by OpenFlare |
|
||||
| Frontend | Manages website configurations, WAF, origins, certificates, nodes, versions, users, settings, and observability pages |
|
||||
|
||||
## Server
|
||||
|
||||
`openflare_server` is a monolithic control plane based on Gin, GORM, SQLite/PostgreSQL, the existing login/session system, and the static frontend build.
|
||||
`openflare_server` is a monolithic control plane:
|
||||
|
||||
It owns the admin UI and API, Agent API, configuration rendering, version publishing, storage, and aggregate queries.
|
||||
* Gin provides HTTP services.
|
||||
* GORM accesses SQLite or PostgreSQL.
|
||||
* The existing login system provides management console Sessions.
|
||||
* Authentication source and external account binding support GitHub OAuth and standard OIDC.
|
||||
* The Go Server hosts the static build output of `openflare_server/web`.
|
||||
|
||||
The Server does not directly SSH into nodes, nor does it modify node files online. It only saves the control plane state, generates complete configuration versions, and lets nodes actively pull them via the Agent API.
|
||||
|
||||
## Agent
|
||||
|
||||
`openflare_agent` is a single Go binary that runs on each node. It controls OpenResty through `openresty_path`, or `openresty` by default. Docker deployments use an Agent image that already includes OpenResty and follows the same binary-control flow.
|
||||
`openflare_agent` is a Go monolithic application:
|
||||
|
||||
It handles registration, heartbeat, sync, file writes, `openresty -t`, reload, rollback, self-update, and lightweight collection.
|
||||
* Runs on nodes as a single binary.
|
||||
* Reads or generates local node information upon startup.
|
||||
* Performs periodic heartbeats to report status and fetch the active version summary.
|
||||
* Pulls configurations, backs up old files, writes new files, validates, and reloads upon discovering a new version.
|
||||
* Attempts to restore execution and roll back when the application fails.
|
||||
* Maintains the WAF GeoIP mmdb; writes the built-in initial database on startup and updates it regularly based on configuration.
|
||||
|
||||
The Agent uniformly executes validations, reloads, starts, and restarts via the OpenResty binary pointed to by `openresty_path`; it falls back to calling `openresty` by default when not configured. In Docker deployments, the Agent image includes the OpenResty binary and follows the same binary control logic.
|
||||
|
||||
Node IPs are maintained by Agent registration and heartbeat reports by default; when the admin UI locks a node IP, the Server continues updating runtime fields such as status, versions, and observability, but no longer accepts Agent reports to overwrite that IP.
|
||||
|
||||
## Frontend
|
||||
|
||||
`openflare_server/web` is the production frontend baseline: Next.js App Router, React 19, TypeScript, and Tailwind CSS.
|
||||
`openflare_server/web` is the official management console frontend:
|
||||
|
||||
* Next.js App Router.
|
||||
* React 19.
|
||||
* TypeScript.
|
||||
* Tailwind CSS.
|
||||
* TanStack Query manages server state.
|
||||
|
||||
The frontend is hosted by the Go Server after static export. All API requests must go through `lib/api/` uniformly and handle the `success/message/data` response structure.
|
||||
|
||||
## Data and Request Flow
|
||||
|
||||
### Management Console Request Flow
|
||||
|
||||
```text
|
||||
Browser -> Frontend -> /api/* -> controller -> service -> model -> database
|
||||
```
|
||||
|
||||
Mutation APIs on the management console use `POST`, while read-only APIs use `GET`. Both success and failure return a clear `message`.
|
||||
|
||||
### Agent Sync Flow
|
||||
|
||||
```text
|
||||
Agent heartbeat -> Server returns active version summary
|
||||
Agent discovers new version -> Pulls configuration details
|
||||
Agent writes main configuration / route configuration / certificates / Lua resources / WAF runtime configuration
|
||||
Agent executes OpenResty validation and reload
|
||||
Agent reports application result
|
||||
```
|
||||
|
||||
When WebSocket (WS) connection upgrade is enabled by default, the Agent first obtains settings through the HTTP heartbeat, and then attempts to connect to the Agent WebSocket. Once the WS connection is successful, periodic status reporting is carried by WS; when the Server publishes or activates a version, it broadcasts the active version summary to connected Agents, allowing them to enter the synchronization flow immediately. When the WS connection is disconnected or fails to establish, the Agent automatically falls back to the HTTP heartbeat.
|
||||
|
||||
### Reverse Proxy Flow
|
||||
|
||||
```text
|
||||
Client -> OpenResty server block -> WAF Lua -> named upstream -> Origin
|
||||
```
|
||||
|
||||
Website configuration is the aggregation boundary of reverse proxies. A website configuration can bind multiple domains and share site-level traffic limits, reverse proxies, and caching configurations.
|
||||
|
||||
WAF is executed in the OpenResty `access_by_lua_file` phase. Rules come from `waf_config.json` carried in the current active version; the global rule group takes effect by default, and websites can overlay custom rule groups.
|
||||
|
||||
## Core Objects
|
||||
|
||||
Currently active entities include:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
* `managed_domains`
|
||||
* `node_request_reports`
|
||||
* `node_access_logs`
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Reason |
|
||||
| --- | --- |
|
||||
| Complete configuration versions, instead of online patches | Gives previews, activations, history, and rollbacks stable boundaries |
|
||||
| Active pull by Agents | Server does not need SSH permissions, nor does it expose remote command execution entry points |
|
||||
| Global single active version | Reduces MVP complexity and ensures all nodes are consistent by default |
|
||||
| Website configurations aggregate multiple domains | Supports sharing site-level policies for a business site while allowing certificate binding per domain |
|
||||
| Server-side aggregation of observability data | Avoids inconsistent results caused by temporary frontend calculations |
|
||||
|
||||
## Contributor Reading Suggestions
|
||||
|
||||
If you want to modify architecture-related code, read these first:
|
||||
|
||||
1. [Product Boundary](./index.md)
|
||||
2. [Release Model](./release-model.md)
|
||||
3. [Development Constraints](./development.md)
|
||||
4. [Repository Structure](../reference/repository.md)
|
||||
|
||||
@@ -148,6 +148,8 @@ Currently active entities:
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `options`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
|
||||
General constraints:
|
||||
|
||||
@@ -161,6 +163,7 @@ General constraints:
|
||||
* Upstreams uniformly use named `upstream` + keepalive; for a single upstream carrying a base path or query, the original URI should be added back to `proxy_pass`. For multiple upstreams, only pure `scheme://host[:port]` is allowed.
|
||||
* Rate limits, reverse proxy, and cache configurations currently belong to the site-level `proxy_routes`.
|
||||
* HTTPS certificate binding must be saved on a per-domain basis through `domain_cert_ids` parallel to `domains`; domains not bound to a certificate must not participate in HTTPS rendering.
|
||||
* WAF global rule groups are applied to all websites by default, while custom rule groups are bound to site configurations via `waf_rule_group_bindings`; they must be included in the complete configuration version snapshot during publishing.
|
||||
* `config_versions` must save complete snapshots and rendering results.
|
||||
* There can only be one activated version globally at a time.
|
||||
* Rollback is achieved by reactivating older versions.
|
||||
@@ -178,6 +181,19 @@ The database version number is defined in `openflare_server/model`, and it must
|
||||
|
||||
Every time the database version number is upgraded, an explicit migration method from the previous version to the new version must be added. The migration method must contain validation logic after the upgrade; only when the validation passes can the new database version record be written.
|
||||
|
||||
Versions 1 through 7 are treated as the historical initial baseline and no longer keep per-version upgrade files. Starting from v8, database migrations must be placed under `openflare_server/model/migrate` and named after the target version, such as `v16.go`. Each version file registers its migration through `init()`, and the current database version is derived from the highest registered target version. Do not change the semantics of released v8+ migrations merely to reorganize files.
|
||||
|
||||
When performing a database upgrade, complete the following steps:
|
||||
|
||||
1. Decide whether a schema version bump is required: any addition, removal, or rename of tables, columns, indexes, constraints, column types, sharding rules, or persisted-data semantics must upgrade the version.
|
||||
2. Add `openflare_server/model/migrate/vN.go`, where `N` is the target version. The file header must include a comment explaining what this upgrade changes and why it is needed.
|
||||
3. Implement `VN()` in `vN.go`, and call `Register(VN())` from `init()`. `FromVersion` must be `N-1`, and `ToVersion` must be `N`.
|
||||
4. Implement the upgrade logic in `migrateVN`. Use `Context` to call shared capabilities such as `ApplyCurrentSchema`, historical backfills, and default-data initialization; complex data repairs must be explicit and must not rely on `AutoMigrate` alone.
|
||||
5. Implement post-upgrade validation in `validateVN`. Validation must cover at least the existence of new tables/columns/indexes, required default data, and required data backfills.
|
||||
6. If the migration needs new shared backfill or validation helpers, place them in `openflare_server/model/migrations.go` or another suitable model file, and expose them through `Context` to `model/migrate`; avoid reverse-importing `model` from the subpackage and creating an import cycle.
|
||||
7. Add migration tests covering at least upgrade from the `N-1` old database to `N`, including schema version, table/column structure, key data backfills, and validation results. The `model/migrate` registry test checks version continuity, but business-specific migrations still require tests.
|
||||
8. Update design/development docs; if management APIs, configuration fields, or user-visible behavior change, also update the relevant guides, configuration reference, and Swagger documents.
|
||||
|
||||
After starting the new package, the database's current version must be checked first, and then upgraded step by step in order to the target version; skipping intermediate upgrade steps to directly write the target version is prohibited.
|
||||
|
||||
An empty database initialization can directly establish the current version structure, but the same-version validation must still be executed after the initialization is completed, and the current database version must be persisted.
|
||||
@@ -232,14 +248,18 @@ Version constraints:
|
||||
The Agent must satisfy:
|
||||
|
||||
* Read or generate local `node_id` after startup.
|
||||
* Periodic heartbeat and synchronization.
|
||||
* Periodic heartbeats and synchronization.
|
||||
* Conventional synchronization prioritizes judging based on the version summary returned by the heartbeat.
|
||||
* When WS connection upgrade is enabled and the connection is successful, the Agent can receive active version summaries via WS and immediately synchronize; WS failure or disconnection must fall back to HTTP heartbeats.
|
||||
* Back up old files first when discovering a new version.
|
||||
* Write main configurations, route configurations, and necessary certificate files.
|
||||
* Write WAF/PoW runtime configurations, and ensure WAF Lua resources are managed uniformly by the Agent.
|
||||
* Execute `openresty -t -c <main_config_path>` after writing the new configuration, and then reload; direct startup of OpenResty is allowed when reload finds that it is not running.
|
||||
* Periodic runtime health checks must not call `openresty -t`, preventing health probes from triggering synchronous upstream domain name resolutions; they should prioritize requesting `/openflare/stub_status` on the local `openresty_observability_port`, using HTTP `200 OK` as the basis for judging that the OpenResty main process and workers are serving.
|
||||
* If the activation of the new configuration fails, the Agent must first try to restore execution with the target configuration, then roll back to the old configuration and pull up OpenResty again.
|
||||
* Report warning when OpenResty recovers normally after rollback; report failure when it still cannot recover after rollback.
|
||||
* Once a target `version + checksum` fails to apply and rolls back, the Agent must block repeated applications of this target in its local state.
|
||||
* Report warning when OpenResty recovers normally after rollback; if there is no historical main configuration to restore locally, it must be allowed to write the built-in safe fallback configuration and pull up an OpenResty runtime state that only listens to port `80` externally and uniformly returns `503 Service Unavailable` and `OpenFlare: No Valid Configuration`, while retaining the local `stub_status` health check entry. The fallback runtime state must not clear the blocked status of the failed target; the application logs must reflect that the target version failed but the fallback runtime has started. Report failure when there is a historical main configuration but it still cannot recover after rollback.
|
||||
* Once a target `version + checksum` application fails and rolls back, the Agent must block repeated applications of this target in its local state.
|
||||
* When the Agent maintains the local MaxMind mmdb, download or refresh failures can only record warnings, and must not block heartbeats, synchronization, configuration application, or OpenResty health checks.
|
||||
|
||||
## Frontend Requests, State, and Types
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ OpenFlare is currently not positioned as a general-purpose log platform, service
|
||||
| Agent Synchronization | Supports registration, heartbeat, synchronization, application result reporting, and self-updating |
|
||||
| OpenResty Hosting | Manages main configuration templates, performance parameters, cache parameters, and Lua resources |
|
||||
| HTTPS/TLS | Hosts certificates and domain assets, and binds certificates on a per-domain basis |
|
||||
| WAF | Maintains IP/IP ranges black/whitelists and country-level geographical black/whitelists with global and website-customized rule groups |
|
||||
| Basic Observability | Aggregates node requests, resource snapshots, health events, and access analytics |
|
||||
| Node Management | Node status, token systems, deployment, and update links |
|
||||
| Console Frontend | Next.js-based official management console |
|
||||
@@ -76,6 +77,8 @@ Currently active entities:
|
||||
* `node_metric_snapshots`
|
||||
* `traffic_analytics_rollups`
|
||||
* `node_health_events`
|
||||
* `waf_rule_groups`
|
||||
* `waf_rule_group_bindings`
|
||||
|
||||
## Site Configuration Constraints
|
||||
|
||||
@@ -116,6 +119,24 @@ During publishing rendering:
|
||||
* Domains not bound to a certificate must not be automatically brought into HTTPS.
|
||||
* All domains in `proxy_routes.domains` must be included in the same site configuration to avoid the same site being split in version snapshots.
|
||||
|
||||
## WAF Constraints
|
||||
|
||||
WAF uses rule groups as configuration boundaries. The system fixes a global rule group, which is applied to all websites by default; websites can overlay multiple custom rule groups.
|
||||
|
||||
Phase 1 supports:
|
||||
|
||||
* IP / IP range whitelists and blacklists.
|
||||
* Country-level region whitelists and blacklists.
|
||||
* Rule group-level blocking status codes and response pages, defaulting to `418` and an empty page.
|
||||
|
||||
Evaluation order:
|
||||
|
||||
* Whitelists are bypass exceptions; if any enabled rule group matches a whitelist, the request is allowed.
|
||||
* If no whitelist is matched, blacklists continue to be evaluated.
|
||||
* When multiple blacklists match, the global rule group takes precedence, followed by custom rule groups in ascending order of their IDs.
|
||||
|
||||
Region recognition is based on the MaxMind mmdb maintained locally on the node by the Agent, and the OpenResty Lua reads the local database during the request path. When GeoIP dependencies are unavailable, region rules must be skipped, without affecting IP rules and the reverse proxy main link.
|
||||
|
||||
## Authentication Source Constraints
|
||||
|
||||
`auth_sources` is the configuration object for third-party login entries on the management console, currently supporting only two types: `github` and `oidc`. Enabled authentication sources will be displayed on the login page.
|
||||
|
||||
@@ -15,13 +15,14 @@ Modify rules -> Preview / View diff -> Publish -> Generate complete configuratio
|
||||
When publishing, the Server must:
|
||||
|
||||
1. Read all enabled `proxy_routes`.
|
||||
2. Read the OpenResty main configuration template, performance parameters, cache parameters, and necessary Lua resources on the Server side.
|
||||
2. Read the Server side OpenResty main configuration template, performance parameters, cache parameters, and necessary Lua resources.
|
||||
3. Read domain and certificate binding relationships.
|
||||
4. Render the complete OpenResty configuration.
|
||||
5. Calculate the `checksum`.
|
||||
6. Write to `config_versions`.
|
||||
7. Switch the activated version.
|
||||
8. Let the Agent discover and apply it in subsequent heartbeats.
|
||||
4. Read the WAF global rule group, custom rule groups, and website binding relationships.
|
||||
5. Render the complete OpenResty configuration and WAF runtime configuration.
|
||||
6. Calculate the `checksum`.
|
||||
7. Write to `config_versions`.
|
||||
8. Switch the activated version.
|
||||
9. Let the Agent discover and apply it in subsequent heartbeats.
|
||||
|
||||
The version number format is fixed as `YYYYMMDD-NNN`.
|
||||
|
||||
@@ -33,9 +34,9 @@ Publishing generates a new complete configuration version. The version must cont
|
||||
|
||||
## Activating Version
|
||||
|
||||
There can only be one activated version globally at a time.Differentiated versions grouped by nodes are currently not supported.
|
||||
There can only be one activated version globally at a time. Differentiated versions grouped by nodes are currently not supported.
|
||||
|
||||
The Agent obtains the activated version summary through the heartbeat; only when the remote version or checksum is inconsistent with the local state does the Agent enter the synchronization flow.
|
||||
The Agent obtains the activated version summary through the heartbeat; only when the remote version or checksum is inconsistent with the local state does the Agent enter the synchronization flow. When Agent WS connection upgrade is enabled and the connection is available, the Server will broadcast the latest active version summary after successfully publishing or activating a version. Upon receiving it, the Agent immediately pulls and applies the configuration using the ordinary synchronization flow. When WS is unavailable, changes are still discovered at HTTP heartbeat intervals.
|
||||
|
||||
## Immutable History
|
||||
|
||||
@@ -53,12 +54,12 @@ When discovering a new version, the Agent will:
|
||||
|
||||
1. Pull the details of the target version.
|
||||
2. Back up old files.
|
||||
3. Write the main configuration, route configurations, certificates, and necessary Lua resources.
|
||||
3. Write the main configuration, route configurations, certificates, necessary Lua resources, and WAF/PoW runtime configurations.
|
||||
4. Execute OpenResty configuration verification.
|
||||
5. reload; if it is not started during runtime, try to start OpenResty with the current configuration.
|
||||
6. Report success, warning, or failure.
|
||||
|
||||
If the activation of the new configuration fails, the Agent must try to restore execution; report a warning when the rollback succeeds, and report a failure when it still cannot recover after rollback.
|
||||
If the activation of the new configuration fails, the Agent must try to restore execution; report a warning when the rollback succeeds. If there is no historical main configuration to roll back to locally, the Agent will write the built-in safe fallback configuration and try to pull up OpenResty: this configuration only listens to port `80` externally, contains no user routes, uniformly returns `503 Service Unavailable` and `OpenFlare: No Valid Configuration`, and retains the local `stub_status` health check entry. If fallback startup is successful, it still blocks the failed target version and reports a warning; report a failure when there is a historical main configuration but it still cannot recover after rollback.
|
||||
|
||||
Once a target `version + checksum` application fails and rolls back, the Agent will block repeated applications of this target in its local state. Only when the remote activated version or checksum changes is it allowed to try again.
|
||||
|
||||
@@ -69,3 +70,4 @@ Once a target `version + checksum` application fails and rolls back, the Agent w
|
||||
* The Agent API is fixed to use the node-exclusive `agent_token`; the first access can use the `discovery_token`.
|
||||
* The Server does not provide remote shell or arbitrary command execution entries.
|
||||
* The configuration version must save complete snapshots, rendering results, and `checksum`.
|
||||
* WAF rule groups and website binding relationships must enter the snapshot and checksum along with the complete configuration version, and must not rely on the current mutable WAF configuration when rolling back.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Deployment
|
||||
|
||||
You will learn the recommended OpenFlare deployment model, Server and Agent requirements, source startup workflow, integration steps, upgrade paths, and uninstall entry points.
|
||||
You will learn: The recommended OpenFlare deployment model, Server and Agent requirements, source startup workflow, integration steps, upgrade paths, and uninstall entry points.
|
||||
|
||||
For production, use PostgreSQL for the Server database and set `SESSION_SECRET` explicitly. Agent controls OpenResty through the OpenResty binary; Docker deployments run the Agent image that already includes OpenResty.
|
||||
For production, use PostgreSQL for the Server database and set `SESSION_SECRET` explicitly. The recommended deployment method for the Agent is Docker deployment (i.e., running the Agent image that already includes OpenResty); it also supports shell-script installation or running manually.
|
||||
|
||||
## Topology
|
||||
|
||||
@@ -40,9 +40,10 @@ Agent:
|
||||
| --- | --- |
|
||||
| OS | Install script supports Linux and macOS. systemd service is created only on Linux + systemd. |
|
||||
| Architecture | `amd64` or `arm64` |
|
||||
| OpenResty | Required for local Agent installs |
|
||||
| OpenResty | Required for local Agent installs, or specified via `--openresty-path` |
|
||||
| Docker | Required only when running the Agent Docker image |
|
||||
| Network | Agent node must reach the Server URL |
|
||||
| GeoIP | WAF regional rules use local MaxMind mmdb; Agent initializes a built-in library and updates it periodically |
|
||||
|
||||
[Needs confirmation: recommended production CPU, memory, and disk size]
|
||||
|
||||
@@ -128,7 +129,37 @@ Default port is `3000`. You can also set it explicitly:
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
## Connect Agent
|
||||
## Run Agent in Docker (Recommended)
|
||||
|
||||
Docker deployment is the recommended deployment method for the Agent. In Docker deployments, directly run the Agent image. This image is built on top of the OpenResty image and includes both the Agent controller and the OpenResty binary. When `node_ip` is not explicitly configured, the Agent prioritizes obtaining the real public egress IP via a third-party API, avoiding registering the Docker bridge address as the node IP.
|
||||
|
||||
Mounting the configuration file:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v openflare-agent-data:/data \
|
||||
-v ./agent.json:/etc/openflare/agent.json:ro \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
Using environment variables:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## Connect Agent (Script Installation)
|
||||
|
||||
In addition to Docker deployment, you can also deploy the Agent on the local host using our installation script.
|
||||
|
||||
With `discovery_token`:
|
||||
|
||||
@@ -199,6 +230,10 @@ Minimal `agent.json`:
|
||||
|
||||
When `openresty_path` is not configured, Agent runs `openresty`.
|
||||
|
||||
By default, the Agent will attempt to upgrade to a WebSocket after a successful HTTP heartbeat. When the upgrade succeeds, the Server immediately notifies the Agent of any configuration publications or activations; if the WebSocket cannot be established or is unexpectedly disconnected, the Agent automatically falls back to HTTP heartbeat synchronization.
|
||||
|
||||
WAF regional rules rely on the Agent's local `GeoLite2-Country.mmdb`. Upon startup, the Agent initializes a built-in database at `data_dir/etc/openflare/GeoLite2-Country.mmdb` and attempts to update it periodically based on configuration; update failures only record warnings, and do not affect configuration synchronization or OpenResty reload.
|
||||
|
||||
## Minimal Integration Flow
|
||||
|
||||
1. Start Server and sign in.
|
||||
@@ -220,6 +255,7 @@ Server:
|
||||
Agent:
|
||||
|
||||
* Agents follow stable releases by default.
|
||||
* Agent autoupdate requires the GitHub Release to include both the target binary and a matching `.sha256` checksum file; the download must pass SHA-256 validation before the local executable is replaced.
|
||||
* The install script can be rerun to reinstall or upgrade.
|
||||
* Preview upgrades require manual action.
|
||||
|
||||
|
||||
@@ -102,7 +102,26 @@ Prepare one of them in the management UI before continuing.
|
||||
|
||||
[Needs confirmation: exact UI menu path for creating or viewing `discovery_token` and node `agent_token`]
|
||||
|
||||
## 3. Install Agent
|
||||
## 3. Install/Run Agent
|
||||
|
||||
The recommended deployment method for the Agent is Docker deployment (i.e., running the Agent image that already includes OpenResty); it also supports shell-script installation on the local host.
|
||||
|
||||
### Option A: Run Agent in Docker (Recommended)
|
||||
|
||||
Run the Agent Docker image on the proxy node:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v openflare-agent-data:/data \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
### Option B: Run the Installation Script (Local Host)
|
||||
|
||||
Run the install script on the proxy node.
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ Node pages answer three questions:
|
||||
| Which version is running? | Current version on the node detail page |
|
||||
| Did the last apply succeed? | Apply logs |
|
||||
|
||||
Node IPs are filled automatically by Agent registration and subsequent heartbeats by default. When you enter or change an IP in the admin UI, the node editor enables "Lock node IP" by default; Agent reports will not overwrite the IP while the lock is enabled. After unlocking, the next Agent heartbeat or WebSocket status report can update it again.
|
||||
|
||||
Access analytics and resource snapshots provide basic observability. OpenFlare only keeps access details for a controlled time window; it is not a general-purpose log platform. Use a dedicated logging system for long-term log search.
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# Agent Unified OpenResty Binary Control Scheme
|
||||
|
||||
## Summary
|
||||
|
||||
Unify the Agent running model as "write to the managed configuration file, then call the `openresty` binary to execute `-t`, reload, start/restart". Docker deployment no longer has the Agent control another OpenResty container, but instead provides an independent `ghcr.io/rain-kl/openflare-agent` image; this image is based on `openresty/openresty`, with the Agent controller and OpenResty binary built-in.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Agent runtime:
|
||||
- Remove the DockerExecutor / Docker container management logic from the production path.
|
||||
- Default to using `openresty` when `openresty_path` is not configured.
|
||||
- Uniformly execute binary calls with `-c <main_config_path>` to avoid misreading the OpenResty default configuration.
|
||||
- The apply flow is: backup -> write files -> `openresty -t -c ...` -> reload; if reload indicates it is not running, then start.
|
||||
- restart uses `openresty -c ... -s quit` followed by `openresty -c ...` to start, keeping fault tolerance for missing PIDs.
|
||||
|
||||
- Configurations and File Responsibilities:
|
||||
- Keep parser compatibility for old fields `openresty_container_name`, `openresty_docker_image`, and `docker_binary`, but mark them as deprecated and no longer involved in control logic.
|
||||
- Add `access_log_path`, defaulting to `data_dir/var/log/openflare/access.log`, and no longer placing access logs inside `conf.d`.
|
||||
- Add `runtime_config_dir`, defaulting to `data_dir/etc/openflare`, where `pow_config.json` is written.
|
||||
- `cert_dir` only writes certificate/key files; `lua_dir` only writes Lua code and static resources.
|
||||
- Support splitting files before writing: certificate files go into `cert_dir`, and `pow_config.json` goes into `runtime_config_dir`.
|
||||
|
||||
- Docker Agent Image:
|
||||
- Add `openflare_agent/Dockerfile`, with the runtime image based on `openresty/openresty:alpine`.
|
||||
- Defaults to `OPENFLARE_OPENRESTY_PATH=openresty` and `OPENFLARE_DATA_DIR=/data`.
|
||||
- Expose `80`, `443`, and `18081`.
|
||||
- Support mounting `/etc/openflare/agent.json`, and also support environment variable configurations.
|
||||
- CI publishes independent multi-architecture images: `ghcr.io/rain-kl/openflare-agent:<version>` and `latest`.
|
||||
|
||||
- Agent Configuration Entry:
|
||||
- Keep `-config` + `agent.json`.
|
||||
- Add environment variable overrides/fallbacks: `OPENFLARE_SERVER_URL`, `OPENFLARE_AGENT_TOKEN`, `OPENFLARE_DISCOVERY_TOKEN`, `OPENFLARE_NODE_NAME`, `OPENFLARE_NODE_IP`, `OPENFLARE_DATA_DIR`, `OPENFLARE_OPENRESTY_PATH`, `OPENFLARE_HEARTBEAT_INTERVAL`, `OPENFLARE_REQUEST_TIMEOUT`, `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT`.
|
||||
- If the configuration file does not exist but environment variables are sufficient, the Agent can start directly; if both exist, environment variables override file values.
|
||||
|
||||
- Scripts and Documentation:
|
||||
- `install-agent.sh` becomes a local OpenResty deployment script, adding `--openresty-path` and automatically finding `openresty` when not passed.
|
||||
- `uninstall-agent.sh` only uninstalls the Agent itself, and no longer deletes the Docker OpenResty container or image.
|
||||
- Update architecture, development constraints, deployment instructions, Agent guide, configuration item reference, README, and old Docker control descriptions in English image documents.
|
||||
|
||||
## Public Interfaces
|
||||
|
||||
- Add Agent configuration fields:
|
||||
- `access_log_path`
|
||||
- `runtime_config_dir`
|
||||
|
||||
- Deprecated but compatibly read:
|
||||
- `openresty_container_name`
|
||||
- `openresty_docker_image`
|
||||
- `docker_binary`
|
||||
|
||||
- Add Docker image:
|
||||
- `ghcr.io/rain-kl/openflare-agent`
|
||||
|
||||
- Target Docker execution method examples:
|
||||
- Mount configuration file: `-v ./agent.json:/etc/openflare/agent.json`
|
||||
- Or environment variables: `-e OPENFLARE_SERVER_URL=... -e OPENFLARE_AGENT_TOKEN=...`
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `openflare_agent/internal/config`:
|
||||
- Default `openresty_path` is `openresty`.
|
||||
- Old Docker fields can be read but do not affect the executor.
|
||||
- Environment variables can start the Agent without a configuration file and can override the configuration file.
|
||||
- New default paths conform to responsibility boundaries.
|
||||
|
||||
- `openflare_agent/internal/nginx`:
|
||||
- Binary commands all include `-c <main_config_path>`.
|
||||
- Apply success, reload failure rollback, and start fallback when not running.
|
||||
- `pow_config.json` is no longer written to `cert_dir` or `lua_dir`.
|
||||
- Stale `cert_dir/pow_config.json` and `lua_dir/pow_config.json` will be cleaned up.
|
||||
- access log is rendered to `access_log_path`.
|
||||
- checksum can still uniformly include the main config, route config, certificates, and PoW config into comparisons.
|
||||
|
||||
- Integration Regression:
|
||||
- `cd openflare_agent && GOCACHE=/tmp/openflare-go-cache go test ./...`
|
||||
- `cd openflare_server && GOCACHE=/tmp/openflare-go-cache go test ./...`
|
||||
- Dockerfile build smoke test: build the Agent image and start it using env-only configuration to the executable stage.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Docker Agent image name is fixed as `ghcr.io/rain-kl/openflare-agent`.
|
||||
- Old Docker control fields are compatibly preserved but are no longer a supported behavior.
|
||||
- This phase does not modify Server APIs, does not modify database models, and does not introduce remote command capabilities.
|
||||
- The OpenResty main configuration template continues to be generated by the Server; the Agent is only responsible for local path replacement, file landing, and binary control.
|
||||
@@ -1,78 +1,261 @@
|
||||
# Configuration
|
||||
# Configuration Reference
|
||||
|
||||
You will learn: What configuration sources are supported by OpenFlare Server, frontend builds, and Agents, what the default values of configuration items are, and how common deployment combinations should be configured.
|
||||
|
||||
This document summarizes the Server and Agent configuration items supported by OpenFlare `1.0.0`, retaining only the startup, deployment, and runtime parameters that remain valid.
|
||||
|
||||
## Configuration Sources
|
||||
|
||||
The Server supports three types of configuration sources:
|
||||
|
||||
1. Command-line parameters.
|
||||
2. Environment variables.
|
||||
3. Runtime configurations in the database `Option` table.
|
||||
|
||||
The Agent supports:
|
||||
|
||||
1. `-config` command-line parameter.
|
||||
2. `agent.json` configuration file.
|
||||
3. A few log-related environment variables.
|
||||
|
||||
## Configuration File Locations
|
||||
|
||||
| Component | Default Location | Description |
|
||||
| --- | --- | --- |
|
||||
| Server SQLite | `openflare.db` | Can be modified via `SQLITE_PATH` |
|
||||
| Server Uploads Directory | `upload` | Can be modified via `UPLOAD_PATH` |
|
||||
| Agent Configuration File | `./agent.json` | Can be specified via `-config` |
|
||||
| One-click Install Agent Config | `/opt/openflare-agent/agent.json` | Default generated by the installation script |
|
||||
| Agent Data Directory | `data` under the config directory | Can be modified via `data_dir` |
|
||||
|
||||
## Server CLI Flags
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
| Flag | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `--port` | Server listen port | `3000` |
|
||||
| `--log-dir` | Log directory | empty |
|
||||
| `--version` | Print version and exit | `false` |
|
||||
| `--help` | Print help and exit | `false` |
|
||||
| `--port` | Specify the port the Server listens on | `3000` |
|
||||
| `--log-dir` | Specify the log directory | empty |
|
||||
| `--version` | Print the current version and exit | `false` |
|
||||
| `--help` | Print help information and exit | `false` |
|
||||
|
||||
## Server Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `PORT` | Server listen port | `3000` |
|
||||
| `GIN_MODE` | Gin mode | release unless `debug` |
|
||||
| `GIN_MODE` | Gin execution mode | `release` unless `debug` |
|
||||
| `LOG_LEVEL` | Log level | `info` |
|
||||
| `SESSION_SECRET` | Session signing secret | random on startup |
|
||||
| `SQLITE_PATH` | SQLite database path | `openflare.db` |
|
||||
| `DSN` | PostgreSQL DSN, preferred over SQLite | empty |
|
||||
| `SESSION_SECRET` | Session signing secret | randomly generated on startup |
|
||||
| `SQLITE_PATH` | SQLite database file path | `openflare.db` |
|
||||
| `DSN` | PostgreSQL DSN, preferred over SQLite when set | empty |
|
||||
| `SQL_DSN` | Legacy PostgreSQL DSN, lower priority than `DSN` | empty |
|
||||
| `REDIS_CONN_STRING` | Redis connection string | empty |
|
||||
| `UPLOAD_PATH` | Upload directory | `upload` |
|
||||
| `AGENT_TOKEN` | Legacy global Agent token | empty |
|
||||
|
||||
When `DSN` and `SQL_DSN` both exist, `DSN` wins. PostgreSQL is preferred when configured. If PostgreSQL is empty and a local SQLite file exists, Server migrates SQLite data at startup.
|
||||
Description:
|
||||
|
||||
* When both `DSN` and `SQL_DSN` exist, `DSN` takes precedence.
|
||||
* When `DSN`/`SQL_DSN` and `SQLITE_PATH` exist simultaneously, PostgreSQL takes precedence.
|
||||
* When the target PostgreSQL database is empty and a local SQLite file exists at `SQLITE_PATH`, the Server automatically migrates SQLite data at startup and prints table-by-table migration progress in the logs.
|
||||
* `SESSION_SECRET` must be explicitly configured in production.
|
||||
* When `REDIS_CONN_STRING` is not configured, related capabilities fall back to in-process implementations.
|
||||
|
||||
## Runtime Options
|
||||
|
||||
The following options are maintained on the settings page of the management console and can be hot-updated:
|
||||
|
||||
| Option | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `AgentHeartbeatInterval` | Agent heartbeat interval (milliseconds) | `10000` |
|
||||
| `AgentWebsocketUpgradeEnabled` | Whether to allow Agents to upgrade to WebSockets after successful HTTP heartbeats | `true` |
|
||||
| `NodeOfflineThreshold` | Node offline threshold (milliseconds) | `120000` |
|
||||
| `AgentUpdateRepo` | Agent self-update repository | `Rain-kl/OpenFlare` |
|
||||
| `GeoIPProvider` | Node/IP region lookup provider | `ipinfo` |
|
||||
| `DatabaseAutoCleanupEnabled` | Whether to enable daily automatic cleanup of observability data | `false` |
|
||||
| `DatabaseAutoCleanupRetentionDays` | In-database retention days, at least 1 day | `30` |
|
||||
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | Global API rate limit count / window | `300` / `180` |
|
||||
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | Global Web rate limit count / window | `300` / `180` |
|
||||
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | Sensitive API rate limit count / window | `100` / `1200` |
|
||||
|
||||
Description:
|
||||
|
||||
* When `DatabaseAutoCleanupEnabled` is enabled, the Server automatically cleans up three types of observability data (`node_access_logs`, `node_metric_snapshots`, `node_request_reports`) at 3:00 AM every day.
|
||||
* `DatabaseAutoCleanupRetentionDays` is the unified retention count and must be greater than or equal to 1.
|
||||
* The management console supports leaving the retention days blank during manual cleanup to directly delete all history of the corresponding datasets.
|
||||
* The GitHub Release pointed to by `AgentUpdateRepo` must provide a matching `.sha256` checksum file for each Agent binary, such as `openflare-agent-linux-amd64.sha256`; the self-update validates this SHA-256 digest before replacing the executable.
|
||||
* Third-party logins no longer use `GitHubOAuthEnabled`, `GitHubClientId`, or `GitHubClientSecret` as primary configuration entries; these legacy options are only used for migration to the default GitHub authentication source during upgrades.
|
||||
* Legacy options for WeChat login are retained for compatibility, but the management console no longer provides WeChat login configuration entries.
|
||||
* Legacy options for Turnstile and backend verification remain, and existing configurations will continue to take effect.
|
||||
|
||||
## OpenResty Parameters
|
||||
|
||||
OpenResty performance and caching parameters continue to be stored uniformly in the `Option` table. Currently common items include:
|
||||
|
||||
* `OpenRestyWorkerProcesses`
|
||||
* `OpenRestyWorkerConnections`
|
||||
* `OpenRestyWorkerRlimitNofile`
|
||||
* `OpenRestyKeepaliveTimeout`
|
||||
* `OpenRestyProxyConnectTimeout`
|
||||
* `OpenRestyProxySendTimeout`
|
||||
* `OpenRestyProxyReadTimeout`
|
||||
* `OpenRestyProxyBufferingEnabled`
|
||||
* `OpenRestyGzipEnabled`
|
||||
* `OpenRestyCacheEnabled`
|
||||
* `OpenRestyCachePath`
|
||||
* `OpenRestyCacheMaxSize`
|
||||
|
||||
These parameters must be validated, saved, and participate in version rendering in a structured way.
|
||||
|
||||
Constraints:
|
||||
|
||||
* The management console no longer exposes `resolver` configuration.
|
||||
* Upstreams are uniformly rendered as named `upstream` blocks with keepalives enabled.
|
||||
* A single upstream carrying a base path or query will append the original URI in `proxy_pass`.
|
||||
* Multiple upstreams still require each upstream to be pure `scheme://host[:port]`, and the protocol must be consistent within the same rule.
|
||||
* `OpenRestyCacheEnabled` is used to enable the caching infrastructure and global default parameters; the actual caching enablement and hit policies (based on URL, suffix, or path) are decided separately by each individual `proxy_routes`.
|
||||
* The default cache key is `$scheme$host$request_uri`.
|
||||
* The default `keepalive_timeout` is `20` seconds, and the default `proxy_connect_timeout` is `3` seconds.
|
||||
* The default event model is `epoll`, and `multi_accept` is enabled by default.
|
||||
* HTTPS listeners use the independent `http2 on;` directive by default to avoid deprecation warnings for `listen ... http2` in newer Nginx/OpenResty versions.
|
||||
|
||||
## Frontend Build Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | Frontend API base path | `/api` |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | Displayed frontend version | `dev` |
|
||||
| `NEXT_DEV_BACKEND_URL` | Local dev backend proxy target | `http://127.0.0.1:3000` |
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | Frontend API request base path | `/api` |
|
||||
| `NEXT_PUBLIC_APP_VERSION` | Frontend displayed version number | `dev` |
|
||||
| `NEXT_DEV_BACKEND_URL` | Dev backend proxy target | `http://127.0.0.1:3000` |
|
||||
|
||||
## Runtime Options
|
||||
## Agent Environment Variables
|
||||
|
||||
The settings page maintains these hot-updatable options:
|
||||
|
||||
| Option | Purpose | Default |
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `AgentHeartbeatInterval` | Agent heartbeat interval in milliseconds | `10000` |
|
||||
| `NodeOfflineThreshold` | Node offline threshold in milliseconds | `120000` |
|
||||
| `AgentUpdateRepo` | Agent update repository | `Rain-kl/OpenFlare` |
|
||||
| `GeoIPProvider` | Node/IP region provider | `ipinfo` |
|
||||
| `DatabaseAutoCleanupEnabled` | Enable daily observability cleanup | `false` |
|
||||
| `DatabaseAutoCleanupRetentionDays` | Retention days | `30` |
|
||||
| `LOG_LEVEL` | Agent log level | `info` |
|
||||
| `OPENFLARE_SERVER_URL` | Control plane URL, can override `agent.json` | empty |
|
||||
| `OPENFLARE_AGENT_TOKEN` | Node-exclusive auth token, can override `agent.json` | empty |
|
||||
| `OPENFLARE_DISCOVERY_TOKEN` | Global token for first registration, can override `agent.json` | empty |
|
||||
| `OPENFLARE_NODE_NAME` | Node name, can override `agent.json` | empty |
|
||||
| `OPENFLARE_NODE_IP` | Node IP, can override `agent.json` | empty |
|
||||
| `OPENFLARE_DATA_DIR` | Agent data directory, can override `agent.json` | empty |
|
||||
| `OPENFLARE_OPENRESTY_PATH` | OpenResty binary path, can override `agent.json` | empty |
|
||||
| `OPENFLARE_HEARTBEAT_INTERVAL` | Heartbeat interval, can override `agent.json` | empty |
|
||||
| `OPENFLARE_REQUEST_TIMEOUT` | Request timeout, can override `agent.json` | empty |
|
||||
| `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT` | Local observability port, can override `agent.json` | empty |
|
||||
| `OPENFLARE_MMDB_PATH` | WAF GeoIP mmdb path, can override `agent.json` | empty |
|
||||
| `OPENFLARE_MMDB_UPDATE_INTERVAL` | WAF GeoIP mmdb update interval, can override `agent.json` | empty |
|
||||
| `OPENFLARE_MMDB_DOWNLOAD_URL` | WAF GeoIP mmdb download URL, can override `agent.json` | empty |
|
||||
|
||||
OpenResty performance and cache options are also stored in the Option table, including `OpenRestyWorkerProcesses`, `OpenRestyWorkerConnections`, `OpenRestyProxyConnectTimeout`, `OpenRestyProxyReadTimeout`, `OpenRestyCacheEnabled`, `OpenRestyCachePath`, and `OpenRestyCacheMaxSize`.
|
||||
## Agent CLI Flags
|
||||
|
||||
`AgentUpdateRepo` releases must publish a matching `.sha256` file for each Agent binary, such as `openflare-agent-linux-amd64.sha256`. Agent self-update verifies the SHA-256 digest before replacing the executable.
|
||||
| Flag | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `-config` | Specify the path to the Agent configuration file | `./agent.json` |
|
||||
|
||||
## Agent Configuration
|
||||
## Agent Configuration Fields
|
||||
|
||||
Agent supports the `-config` CLI flag, an `agent.json` file, and the `LOG_LEVEL` environment variable.
|
||||
|
||||
| Field | Purpose | Required | Default / behavior |
|
||||
| Field | Purpose | Required | Default / Behavior |
|
||||
| --- | --- | --- | --- |
|
||||
| `server_url` | Control plane URL | yes | none |
|
||||
| `agent_token` | Node-specific auth token | one of `agent_token` / `discovery_token` | empty |
|
||||
| `discovery_token` | Global token for first registration | one of `agent_token` / `discovery_token` | empty |
|
||||
| `node_name` | Node name | no | host name |
|
||||
| `node_ip` | Node IP | no | auto-detected; Agent first queries the public egress IP through a third-party API, then falls back to local interfaces |
|
||||
| `agent_token` | Node-exclusive auth token | one of `agent_token`/`discovery_token` | empty |
|
||||
| `discovery_token` | Global token for first registration | one of `agent_token`/`discovery_token` | empty |
|
||||
| `node_name` | Node name | no | automatically uses host name |
|
||||
| `node_ip` | Node IP | no | auto-detected; prioritizes obtaining the real public egress IP via third-party APIs, falling back to local interfaces on failure |
|
||||
| `openresty_path` | OpenResty binary path | no | `openresty` |
|
||||
| `openresty_container_name` | Deprecated Docker-control field, read for compatibility only | no | empty |
|
||||
| `openresty_docker_image` | Deprecated Docker-control field, read for compatibility only | no | empty |
|
||||
| `openresty_observability_port` | Local observability and OpenResty health-check port | no | `18081` |
|
||||
| `docker_binary` | Deprecated Docker-control field, read for compatibility only | no | empty |
|
||||
| `data_dir` | Agent data directory | no | `data` under config directory |
|
||||
| `data_dir` | Agent data directory | no | `data` under the config file directory |
|
||||
| `main_config_path` | OpenResty main config write path | no | `data_dir/etc/nginx/nginx.conf` |
|
||||
| `route_config_path` | Route config write path | no | `data_dir/etc/nginx/conf.d/openflare_routes.conf` |
|
||||
| `access_log_path` | OpenResty access log path | no | `data_dir/var/log/openflare/access.log` |
|
||||
| `runtime_config_dir` | Runtime config directory, including `pow_config.json` | no | `data_dir/etc/openflare` |
|
||||
| `heartbeat_interval` | Heartbeat interval | no | `10000` ms |
|
||||
| `request_timeout` | HTTP timeout | no | `10000` ms |
|
||||
| `cert_dir` | Certificate write directory | no | `data_dir/etc/nginx/certs` |
|
||||
| `openresty_cert_dir` | Certificate read directory in OpenResty config | no | same as `cert_dir` |
|
||||
| `lua_dir` | Lua scripts and static resources write directory | no | `data_dir/etc/nginx/lua` |
|
||||
| `openresty_lua_dir` | Lua read directory in OpenResty config | no | same as `lua_dir` |
|
||||
| `runtime_config_dir` | Agent runtime config write directory, e.g., `pow_config.json` | no | `data_dir/etc/openflare` |
|
||||
| `mmdb_path` | WAF GeoIP mmdb file path | no | `data_dir/etc/openflare/GeoLite2-Country.mmdb` |
|
||||
| `mmdb_update_interval` | WAF GeoIP mmdb update interval | no | `86400000` milliseconds |
|
||||
| `mmdb_download_url` | WAF GeoIP mmdb download URL | no | built-in GeoLite2 Country download URL |
|
||||
| `observability_buffer_path` | Observability buffering file path | no | `data_dir/var/lib/openflare/observability-buffer.json` |
|
||||
| `observability_replay_minutes` | Minutes to automatically replay recent observability data | no | `15` |
|
||||
| `state_path` | Agent local state file path | no | `data_dir/var/lib/openflare/agent-state.json` |
|
||||
| `heartbeat_interval` | Heartbeat interval | no | `10000` milliseconds |
|
||||
| `request_timeout` | HTTP request timeout | no | `10000` milliseconds |
|
||||
|
||||
`heartbeat_interval` and `request_timeout` accept milliseconds or Go duration strings.
|
||||
Description:
|
||||
|
||||
When `node_ip` is not configured, Agent first queries `https://realip.cc` for the real public egress IP, which avoids recording a Docker bridge address in container deployments. If that lookup fails, Agent falls back to local interface detection and prefers a public IPv4 address.
|
||||
* `agent_token` and `discovery_token` cannot both be empty.
|
||||
* `heartbeat_interval` and `request_timeout` support integer milliseconds or Go duration strings.
|
||||
* When the Server runtime option `AgentWebsocketUpgradeEnabled` is enabled, the Agent will attempt to upgrade to a WebSocket after a successful HTTP heartbeat; it automatically falls back to HTTP heartbeats when connection fails or is disconnected.
|
||||
* When `openresty_path` is not configured, `openresty` is called by default.
|
||||
* The Agent's periodic health checks request `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status`, no longer judging runtime health via high-frequency `openresty -t`; validation before configuration application, startup recovery, and reloads will still execute `openresty -t -c <main_config_path>`.
|
||||
* The Agent initializes and periodically updates `mmdb_path` for OpenResty WAF Lua to execute country-level geographical rules; update failures only record warnings, and do not block sync or reloads.
|
||||
* If `agent.json` does not exist but environment variables such as `OPENFLARE_SERVER_URL` and tokens are sufficient, the Agent can start directly; environment variables take precedence when both exist.
|
||||
* When the Agent is not configured with `node_ip`, it first queries `https://realip.cc` for the real public egress IP, adapting to Docker/NAT scenarios; it falls back to local interface detection on failure, preferring a public IPv4 address.
|
||||
* When the Agent automatically detects a private `node_ip`, the Server prioritizes retaining the public address of the Agent's direct connection during registration/heartbeat phases, avoiding misregistering internal interface addresses in NAT or multi-interface scenarios.
|
||||
* When "Lock node IP" is enabled in the admin UI, the Server keeps the manually configured node IP and Agent registration, HTTP heartbeat, or WebSocket status reports will not overwrite that field; after unlocking, the next report can fill it again.
|
||||
|
||||
## Common Configuration Combinations
|
||||
|
||||
### Production Server + PostgreSQL
|
||||
|
||||
```bash
|
||||
export SESSION_SECRET='replace-with-a-long-random-string'
|
||||
export DSN='postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable'
|
||||
export GIN_MODE='release'
|
||||
export LOG_LEVEL='info'
|
||||
```
|
||||
|
||||
### Local Server + SQLite
|
||||
|
||||
```bash
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
### Agent + Default OpenResty
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://your-server:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "/opt/openflare-agent/data",
|
||||
"openresty_path": "openresty",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
### Agent + Customized OpenResty Path
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://your-server:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "/var/lib/openflare-agent",
|
||||
"openresty_path": "/usr/local/openresty/nginx/sbin/openresty",
|
||||
"main_config_path": "/var/lib/openflare-agent/etc/nginx/nginx.conf",
|
||||
"route_config_path": "/var/lib/openflare-agent/etc/nginx/conf.d/openflare_routes.conf",
|
||||
"access_log_path": "/var/lib/openflare-agent/var/log/openflare/access.log",
|
||||
"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",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
## Maintenance Requirements
|
||||
|
||||
When the following contents change, this document must be updated in sync:
|
||||
|
||||
* Server command-line parameters.
|
||||
* Server environment variables.
|
||||
* Agent command-line parameters.
|
||||
* Agent configuration fields.
|
||||
* Default values, purposes, or examples of any configuration items.
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
# 本地开发
|
||||
|
||||
你会学到:如何搭建 OpenFlare 的本地开发环境、启动 Server、Agent 和管理端前端,运行测试与构建命令,并理解贡献代码前需要遵守的边界。
|
||||
|
||||
本页面向贡献者。产品边界、数据模型约束、API 约定和前端分层规范以 [开发约束](../design/development.md) 为准;本页只提供可执行的本地开发流程。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL 单体控制面 |
|
||||
| `openflare_server/web` | Next.js 管理端前端,静态导出后由 Go Server 托管 |
|
||||
| `openflare_agent` | Go 单体 Agent,运行在节点侧 |
|
||||
| `scripts` | Agent 安装与卸载脚本 |
|
||||
| `docs` | VitePress 文档站 |
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| pnpm | 推荐通过 `corepack enable` 使用项目声明版本 |
|
||||
| Docker | Server 容器、本地联调和 Agent Docker 镜像需要 |
|
||||
| OpenResty | 本地运行 Agent 时需要可执行 `openresty` |
|
||||
| PostgreSQL | 可选;未配置时 Server 使用 SQLite |
|
||||
|
||||
## 初始化前端依赖
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install
|
||||
```
|
||||
|
||||
构建供 Go Server 托管的静态产物:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 启动 Server
|
||||
|
||||
SQLite 模式:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export SQLITE_PATH='./openflare-dev.db'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
PostgreSQL 模式:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
export SESSION_SECRET='dev-session-secret'
|
||||
export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable'
|
||||
export LOG_LEVEL='debug'
|
||||
go run .
|
||||
```
|
||||
|
||||
默认访问地址:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
默认账号是 `root` / `123456`。
|
||||
|
||||
## 启动前端开发服务器
|
||||
|
||||
前端开发服务器默认监听 `3001`,并通过 `NEXT_DEV_BACKEND_URL` 代理到后端:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
export NEXT_DEV_BACKEND_URL='http://127.0.0.1:3000'
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
```text
|
||||
http://localhost:3001
|
||||
```
|
||||
|
||||
## 启动 Agent
|
||||
|
||||
创建本地 `agent.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"heartbeat_interval": 10000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
export LOG_LEVEL='debug'
|
||||
go run ./cmd/agent -config ./agent.json
|
||||
```
|
||||
|
||||
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。调试时可显式配置 `openresty_path`、`main_config_path`、`route_config_path`、`access_log_path`、`cert_dir`、`lua_dir` 和 `runtime_config_dir`。
|
||||
|
||||
## 测试
|
||||
|
||||
Server:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Agent:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
GOCACHE=/tmp/openflare-go-cache go test ./...
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
Docs:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
管理端静态产物:
|
||||
|
||||
```bash
|
||||
cd openflare_server/web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Server 二进制:
|
||||
|
||||
```bash
|
||||
cd openflare_server
|
||||
go build -o openflare-server .
|
||||
```
|
||||
|
||||
Agent 二进制:
|
||||
|
||||
```bash
|
||||
cd openflare_agent
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
```
|
||||
|
||||
## 调试入口
|
||||
|
||||
| 场景 | 命令或位置 |
|
||||
| --- | --- |
|
||||
| Server 日志 | `LOG_LEVEL=debug go run .` |
|
||||
| Agent 日志 | `LOG_LEVEL=debug go run ./cmd/agent -config ./agent.json` |
|
||||
| Swagger | `http://localhost:3000/swagger/index.html` |
|
||||
| 前端 API 代理 | `NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev` |
|
||||
| OpenResty 配置校验 | `openresty -t -c ./data/etc/nginx/nginx.conf` |
|
||||
|
||||
## 代码风格与变更准入
|
||||
|
||||
贡献前先确认:
|
||||
|
||||
1. 需求符合 [产品边界](../design/index.md)。
|
||||
2. 实现符合 [开发约束](../design/development.md)。
|
||||
3. 不破坏发布、同步、回滚或升级主链路。
|
||||
4. 涉及配置、部署、API 或产品边界时同步更新文档。
|
||||
5. 风险较高的修改补充测试或等效联调验证。
|
||||
|
||||
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
|
||||
+5
-5
@@ -10,7 +10,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
|
||||
|
||||
1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。
|
||||
2. [基础使用](./usage.md):了解网站配置、源站、证书、发布、回滚和观测的常见操作。
|
||||
3. [部署说明](./deployment.md):把 Server 和 Agent 放到更接近生产的环境中运行。
|
||||
3. [部署说明](../reference/deployment.md):把 Server 和 Agent 放到更接近生产的环境中运行。
|
||||
4. [配置项参考](../reference/configuration.md):查 Server 环境变量、运行时 Option 和 Agent 配置字段。
|
||||
5. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
|
||||
|
||||
@@ -20,11 +20,11 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
|
||||
| --- | --- |
|
||||
| 5 分钟内跑起管理端 | [快速开始](./quick-start.md) |
|
||||
| 发布第一条反向代理配置 | [发布第一份配置](./first-site.md) |
|
||||
| 接入或重装节点 Agent | [接入 Agent](./agent.md) |
|
||||
| 从源码启动 Server | [启动 Server](./server.md) |
|
||||
| 接入或重装节点 Agent | [接入 Agent](../reference/agent.md) |
|
||||
| 从源码启动 Server | [启动 Server](../reference/server.md) |
|
||||
| 配置 GitHub 或 OIDC 登录 | [SSO 登录配置](./sso.md) |
|
||||
| 升级 Server 或 Agent | [升级与维护](./upgrade.md) |
|
||||
| 参与开发或修复问题 | [本地开发](./development.md) 与 [开发约束](../design/development.md) |
|
||||
| 升级 Server 或 Agent | [升级与维护](../reference/upgrade.md) |
|
||||
| 参与开发或修复问题 | [本地开发](../design/development.md) 与 [开发约束](../guildline/development-constraints.md) |
|
||||
| 理解架构和发布模型 | [系统架构](../design/architecture.md) 与 [发布模型](../design/release-model.md) |
|
||||
|
||||
## 文档分区
|
||||
|
||||
@@ -102,7 +102,26 @@ Agent 可以用两类凭证接入:
|
||||
|
||||
[需要确认:当前管理端中创建或查看 `discovery_token` 与节点 `agent_token` 的准确菜单路径]
|
||||
|
||||
## 3. 安装 Agent
|
||||
## 3. 安装/运行 Agent
|
||||
|
||||
Agent 部署方式推荐使用 Docker 部署(即直接运行内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本将 Agent 部署在本地宿主机上。
|
||||
|
||||
### 方式 A:Docker 运行 Agent(推荐)
|
||||
|
||||
在代理节点上直接运行 Agent 镜像:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v openflare-agent-data:/data \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
### 方式 B:执行安装脚本(本地部署)
|
||||
|
||||
在代理节点上执行安装脚本。
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ WAF 或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent
|
||||
| 当前运行哪个版本 | 节点详情中的当前版本 |
|
||||
| 最近一次应用是否成功 | 应用记录 |
|
||||
|
||||
节点 IP 默认由 Agent 注册和后续心跳自动回填。若在管理端填写或修改 IP,节点编辑会默认开启“锁定节点 IP”;开启后 Agent 上报不会覆盖该 IP。关闭锁定后,下一次 Agent 心跳或 WebSocket 状态上报会重新按自动逻辑更新。
|
||||
|
||||
访问分析和资源快照用于基础观测。OpenFlare 只保留受控时间窗口内的访问明细,不定位为通用日志平台。如果需要长期日志检索,应接入独立日志系统。
|
||||
|
||||
## 常见场景
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# OpenFlare 特定项目开发准则 (Project Guidelines)
|
||||
|
||||
本文档定义了针对 **OpenFlare** 项目特定的后端开发约束、架构设计模式、GORM 数据库交互规范以及关键的 JSON 序列化避坑指南。所有参与项目后端开发的代码必须严格遵守。
|
||||
|
||||
---
|
||||
|
||||
## 1. 统一接口输入与响应处理(Controller 约束)
|
||||
|
||||
为了保证 API 的一致性,并消除控制器层中大量的样板代码,所有 Gin Controller 必须遵守以下规范:
|
||||
|
||||
### 1.1 参数解析与绑定
|
||||
- **URL ID 参数解析**:必须调用统一的 `parseIDParam(c)` 辅助函数。严禁手写 `strconv.ParseUint(c.Param("id"), ...)`。
|
||||
- **JSON 请求体绑定**:必须调用统一的 `bindJSON(c, &input)` 辅助函数。严禁手动调用 `c.ShouldBindJSON` 或 `json.NewDecoder` 并重复编写错误返回逻辑。
|
||||
|
||||
### 1.2 标准 API 响应
|
||||
- 所有控制器方法的返回必须统一使用 `respondSuccess`、`respondFailure`、`respondBadRequest` 等标准方法。
|
||||
- **严禁手写** `c.JSON(http.StatusOK, gin.H{...})`,以确保全局 API 响应字段结构(`success`/`message`/`data`)的百分之百一致。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 接口的入参解析与响应统一规范定义在 [openflare_server/controller/response.go](file:///Users/ryan/DEV/Go/OpenFlare/openflare_server/controller/response.go) 中。
|
||||
|
||||
---
|
||||
|
||||
## 2. 纯净工具类与数据库逻辑完全隔离(Utils 约束)
|
||||
|
||||
为了确保代码的可测试性、高内聚和低耦合,`utils/` 目录下的工具包必须保持纯净性:
|
||||
|
||||
### 2.1 无副作用与解耦原则
|
||||
- 所有底层客户端与外部服务对接包(如 `utils/acme` 证书操作、邮件发送、DNS 供应商对接等)**必须完全剥离数据库或 GORM 依赖**。
|
||||
- 工具包中严禁导入 `openflare/model` 包或直接访问数据库连接。它们应当只接受基础数据类型(如 `string`、`[]byte` 等)或本地无依赖结构体作为输入,并返回纯粹的计算或请求结果。
|
||||
|
||||
### 2.2 业务服务层(Service)职责
|
||||
- 业务服务层 `service/` 负责数据库实体的加载、组装、事务持久化,并将底层的具体网络或加密操作委托给 `utils/` 工具包。
|
||||
- 这样不仅保证了底层工具类的百分之百可单元测试性,也维护了清晰的系统分层。
|
||||
|
||||
---
|
||||
|
||||
## 3. Go 泛型切片去重与 JSON 序列化陷阱(Slice 约束)
|
||||
|
||||
在进行切片操作和去重时,必须使用泛型辅助函数,并注意 Go Slice 的空/零值在 JSON 序列化中的表现。
|
||||
|
||||
### 3.1 避免重复编写 map-seen 逻辑
|
||||
- 禁止在 `service/` 或 `model/` 中手写临时的 map-seen 去重样板代码。
|
||||
- 必须统一调用基于 Go 泛型实现的 [openflare_server/utils/slice.go](file:///Users/ryan/DEV/Go/OpenFlare/openflare_server/utils/slice.go) 中的 `utils.Unique()` 辅助函数。
|
||||
|
||||
### 3.2 关键的 JSON 序列化规则(Nil vs. Empty Slice)
|
||||
在 Go 中,未初始化的 `nil` 切片和已初始化的空切片 `[]T{}` 在内存中不同,它们在序列化为 JSON 时也有着决定性的区别:
|
||||
- **`nil` 切片**:序列化为 JSON `null`。
|
||||
- **空切片 (`make([]T, 0)`)**:序列化为 JSON `[]`。
|
||||
|
||||
> [!CAUTION]
|
||||
> **开发避坑准则**:
|
||||
> 1. GORM 数据库的很多 JSON/Array 字段(例如 `domain_cert_ids`、`upstreams` 等)或配置版本变更检测机制(如 `checksum` 计算和 `diff` 检测),要求空数组在 JSON 中必须表示为 `[]` 而非 `null`,否则会触发重复发布或解析失败的 bug。
|
||||
> 2. `utils.Unique` 必须具备 **Nil-Preservation(空值保留)** 特性:
|
||||
> - 如果传入的 Slice 是 `nil`,它必须返回 `nil`,以支持 `omitempty` 或在需要表示“缺失”的场景中输出 `null`。
|
||||
> - 如果传入的 Slice 不是 `nil`(即使长度为 0 或去重后长度为 0),它必须返回非 nil 的空切片 `make([]T, 0)`,以确保序列化为 `[]`。
|
||||
> 3. 所有类似的切片加工辅助函数都必须遵循此行为。
|
||||
@@ -0,0 +1,242 @@
|
||||
# 开发约束
|
||||
|
||||
你会学到:OpenFlare 代码修改的准入标准、后端/Agent/前端分层约束、数据模型边界、API 约定、数据库迁移要求和测试交付基线。
|
||||
|
||||
本文档融合原开发规范、前端规范与开发计划,是 OpenFlare `1.0.0` 之后的工程约束入口。
|
||||
|
||||
## 当前结论
|
||||
|
||||
* 第一版至第六版的主线能力已经全部完成。
|
||||
* `1.0.0` 是当前正式基线。
|
||||
* 已完成阶段的过程性任务以代码、测试与 Git 历史为准。
|
||||
* 新工作优先以缺陷修复、可维护性改进、文档与测试补强为主。
|
||||
|
||||
当前开发优先级:
|
||||
|
||||
1. 稳定性。
|
||||
2. 升级与回滚链路可靠性。
|
||||
3. 文档准确性。
|
||||
4. 测试覆盖补强。
|
||||
5. 在既有边界内的小步迭代。
|
||||
|
||||
## 变更准入
|
||||
|
||||
新需求进入实现前,按以下顺序判断:
|
||||
|
||||
1. 是否符合 [产品边界](../design/index.md)。
|
||||
2. 是否符合本文档的后端、Agent 与前端约束。
|
||||
3. 是否会破坏现有发布、同步、回滚或升级主链路。
|
||||
4. 是否需要同步更新部署、配置、README 或文档站页面。
|
||||
|
||||
如果需求超出边界或引入新基础设施,应先更新设计文档,再开始实现。
|
||||
|
||||
任何合入正式基线的改动,至少应满足:
|
||||
|
||||
* 不破坏 Agent 心跳、同步、发布与回滚主链路。
|
||||
* 不破坏现有 OpenResty 主配置托管模型。
|
||||
* 不降低总览、节点详情与访问分析的既有可用性。
|
||||
* 有与风险相称的测试或联调验证。
|
||||
* 文档与代码保持一致。
|
||||
|
||||
## 技术基线
|
||||
|
||||
Server:
|
||||
|
||||
* Go 1.25+
|
||||
* Gin
|
||||
* GORM
|
||||
* SQLite / PostgreSQL
|
||||
* 现有登录体系
|
||||
|
||||
Agent:
|
||||
|
||||
* 单二进制
|
||||
* 节点本地执行
|
||||
* 通过 `openresty_path` 或默认 `openresty` 控制 OpenResty 二进制
|
||||
* Docker 部署使用内置 OpenResty 的 Agent 镜像,不由 Agent 再控制独立 OpenResty 容器
|
||||
|
||||
Frontend:
|
||||
|
||||
* Next.js 15 App Router
|
||||
* React 19
|
||||
* TypeScript 5
|
||||
* Tailwind CSS 4
|
||||
* TanStack Query
|
||||
* React Hook Form + Zod
|
||||
* Zustand 仅用于轻量客户端状态
|
||||
* ESLint + Prettier
|
||||
* Vitest + Testing Library + Playwright
|
||||
* pnpm
|
||||
|
||||
## 工程分层约束
|
||||
|
||||
各组件和模块(Server、Agent、Frontend)的物理目录分层职责详见 [仓库结构](../design/repository.md)。在此结构下,开发必须遵守以下核心分层规则:
|
||||
|
||||
* **Server 开发规则**:禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
|
||||
* **Agent 开发规则**:每个模块职责单一,外部命令调用集中封装,状态落盘与配置落盘分离。
|
||||
* **Frontend 开发规则**:页面文件只负责获取路由参数、组织页面结构、调用 feature 组件;不应手写复杂 API 细节、复杂表单校验逻辑或维护大量彼此耦合的局部状态。
|
||||
|
||||
## 数据模型规范
|
||||
|
||||
在定义和修改 Go/GORM 模型实体时,所有模型的业务边界与设计约束必须严格符合 [产品边界](../design/index.md)。
|
||||
|
||||
### 1. 当前有效实体
|
||||
* **核心配置与反代**:`proxy_routes` (网站配置), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书), `managed_domains` (托管域名).
|
||||
* **节点与状态**:`nodes` (节点), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
|
||||
* **观测与分析**:`node_request_reports` (请求上报), `node_access_logs` (访问明细), `node_metric_snapshots` (指标快照), `traffic_analytics_rollups` (流量聚合), `node_health_events` (健康事件).
|
||||
* **系统配置与第三方登录**:`options` (全局参数), `auth_sources` (第三方认证源), `external_accounts` (外部绑定账号).
|
||||
* **安全与 WAF**:`waf_rule_groups` (WAF规则组), `waf_rule_group_bindings` (网站WAF绑定).
|
||||
|
||||
### 2. 底层数据库技术约束
|
||||
在编写或修改模型时,必须严格遵守以下持久化与数据库设计准则:
|
||||
* **禁止随意引入平台化新实体**:除非 [产品边界](../design/index.md) 设计发生调整并经评审。
|
||||
* **业务唯一性保障**:
|
||||
* `proxy_routes.site_name` 作为业务唯一主标识。
|
||||
* `proxy_routes.domains` 中的各域名必须全局唯一,不可跨站点冲突,列表第一项视为主域名。
|
||||
* **兼容字段处理**:遗留的 `proxy_routes.domain` 只能作为 `domains[0]` 的只读/兼容镜像,新代码不得以该字段为唯一业务输入。
|
||||
* **多上游及 Keepalive**:单上游时应支持 base path/query 并在 `proxy_pass` 中正确补齐 URI;多上游负载均衡时仅允许纯 `scheme://host[:port]`。
|
||||
* **证书映射**:证书绑定必须通过逐域名平行的 `domain_cert_ids` 字段精确保存,未绑定证书的域名不得参与 HTTPS 渲染。
|
||||
* **版本快照一致性**:`config_versions` 必须保存版本发布时的完整快照及 checksum 校验码,确保渲染结果不可变且全局单激活版本。
|
||||
* **外部账户唯一绑定**:第三方登录必须通过 `external_accounts` 映射至本地唯一用户,原 `users.github_id` 仅用于向后兼容迁移,任何新登录流程禁止以此为业务输入。
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号。
|
||||
|
||||
数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库。
|
||||
|
||||
每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法。迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录。
|
||||
|
||||
v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起,数据库迁移必须放在 `openflare_server/model/migrate` 目录中,并以目标版本命名文件,例如 `v16.go`。每个版本文件通过 `init()` 注册自己的迁移,当前数据库版本取已注册迁移的最大目标版本。不得为了整理文件而改变已发布 v8+ 迁移的语义。
|
||||
|
||||
执行数据库升级时必须按以下步骤完成:
|
||||
|
||||
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` 测试兜底,但具体业务迁移仍必须有测试。
|
||||
8. 同步更新设计/开发文档;如果管理端 API、配置项或用户可见行为变化,还要同步更新对应指南、配置参考和 Swagger 文档。
|
||||
|
||||
新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本。
|
||||
|
||||
空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本。
|
||||
|
||||
如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录。涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试。
|
||||
|
||||
## API 与鉴权
|
||||
|
||||
管理端与 Agent API 统一使用 JSON。成功与失败都必须返回清晰 `message`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
* Agent API 固定放在 `/api/agent/*`。
|
||||
* 总览与节点详情优先使用专用聚合接口。
|
||||
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
|
||||
* 管理端继续复用现有登录、角色与 Session。
|
||||
* 第三方登录统一通过认证源 API 进入,认证源管理接口必须要求 Root Session。
|
||||
* `/api/status` 只能返回已启用认证源的公开字段,不得返回 Client Secret。
|
||||
* 第三方账号未绑定且注册关闭时,应提供绑定已有账号流程,不得自动创建用户。
|
||||
* Agent 正式请求统一使用节点专属 `agent_token`。
|
||||
* 首次接入可使用全局 `discovery_token`。
|
||||
* Agent 请求头统一使用 `X-Agent-Token`。
|
||||
|
||||
禁止暴露远程 shell 或任意命令执行入口,禁止在日志中打印完整 Token,禁止绕过占位符约束保存不可渲染的主配置模板。
|
||||
|
||||
## 发布与运行
|
||||
|
||||
发布逻辑必须保持:
|
||||
|
||||
* 发布时读取全部启用的 `proxy_routes`。
|
||||
* 同时读取 OpenResty 主配置参数、反代性能参数与缓存参数。
|
||||
* 生成完整 OpenResty 配置。
|
||||
* 计算 `checksum`。
|
||||
* 写入 `config_versions`。
|
||||
* 通过切换 `is_active` 激活版本。
|
||||
|
||||
版本约束:
|
||||
|
||||
* 版本号格式固定为 `YYYYMMDD-NNN`。
|
||||
* 不在线修改历史版本。
|
||||
* 不做按节点分组的差异化版本。
|
||||
* 预览与 diff 是只读能力,不产生发布记录。
|
||||
|
||||
Agent 必须满足:
|
||||
|
||||
* 启动后读取或生成本地 `node_id`。
|
||||
* 周期性心跳与同步。
|
||||
* 常规同步优先依据 heartbeat 返回的版本摘要判断。
|
||||
* WS 连接升级开启且连接成功时,Agent 可通过 WS 接收激活版本摘要并立即同步;WS 失败或断开必须退回 HTTP heartbeat。
|
||||
* 发现新版本时先备份旧文件。
|
||||
* 写入主配置、路由配置与必要证书文件。
|
||||
* 写入 WAF/PoW 运行时配置,并确保 WAF Lua 资源由 Agent 统一管理。
|
||||
* 写入新配置后执行 `openresty -t -c <main_config_path>`,再 reload;reload 发现运行时未启动时允许直接启动 OpenResty。
|
||||
* 周期性运行时健康检查不得调用 `openresty -t`,避免健康探针触发 upstream 域名同步解析;应优先请求本地 `openresty_observability_port` 上的 `/openflare/stub_status`,以 HTTP `200 OK` 作为 OpenResty 主进程和 worker 正在提供服务的判断依据。
|
||||
* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty。
|
||||
* 回滚后 OpenResty 恢复正常时上报警告;如果本地没有历史主配置可恢复,必须允许写入内置安全兜底配置并拉起对外只监听 `80` 端口、统一返回 `503` 的 OpenResty 运行态;兜底配置仍需保留本地 `stub_status` 健康检查入口。
|
||||
* 兜底运行态不得清除失败目标的阻断状态;应用记录必须能体现目标版本失败但 fallback runtime 已启动。存在历史主配置但回滚后仍无法恢复运行时上报失败。
|
||||
* 某个目标 `version + checksum` 一旦应用失败并回退,Agent 必须在本地状态中阻断该目标的重复应用。
|
||||
* Agent 维护本地 MaxMind mmdb 时,下载或刷新失败只能记录警告,不得阻断心跳、同步、配置应用或 OpenResty 健康检查。
|
||||
|
||||
## 前端请求、状态与类型
|
||||
|
||||
所有 API 请求必须统一经过 `lib/api/`:
|
||||
|
||||
* 统一处理 `success/message/data` 响应结构。
|
||||
* 统一处理鉴权失效、网络异常和通用错误消息。
|
||||
* 统一维护资源接口与请求路径。
|
||||
|
||||
状态分层:
|
||||
|
||||
* 服务端状态:TanStack Query。
|
||||
* 页面临时状态:组件内部 `useState`。
|
||||
* 跨页面 UI 状态:Zustand。
|
||||
|
||||
要求开启 TypeScript 严格模式,禁止滥用 `any`,API 响应、表单输入、业务实体必须有明确类型。
|
||||
|
||||
## 表单、交互、样式与主题
|
||||
|
||||
表单统一使用 React Hook Form 与 Zod。
|
||||
|
||||
高风险操作必须二次确认、展示操作对象名称,并明确成功与失败反馈。
|
||||
|
||||
样式原则:
|
||||
|
||||
* 统一使用 Tailwind CSS 与现有 token 体系。
|
||||
* 优先复用已有基础组件与布局组件。
|
||||
* 保持视觉层级、留白与语义颜色一致。
|
||||
|
||||
主题要求:
|
||||
|
||||
* 同时支持 `light`、`dark`、`system`。
|
||||
* 用户选择必须持久化。
|
||||
* 首屏尽量避免主题闪烁。
|
||||
|
||||
## 测试与交付
|
||||
|
||||
* 关键业务逻辑必须有单元测试或等效回归测试。
|
||||
* Agent 主链路修改必须验证同步、应用与回滚。
|
||||
* 前端页面至少覆盖加载态、空态、错误态与成功反馈。
|
||||
* Go 版本调整时,同步检查 `go.mod`、Dockerfile 与 CI 工作流。
|
||||
|
||||
## 后续维护方式
|
||||
|
||||
后续规划不再按“大版本阶段文档”维护,而采用以下方式:
|
||||
|
||||
* 产品边界变动:更新 [产品边界](../design/index.md)。
|
||||
* 工程约束变动:更新本文档。
|
||||
* 部署与配置变动:更新 [部署说明](../reference/deployment.md)、[配置项](../reference/configuration.md) 与 README。
|
||||
|
||||
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文档。
|
||||
|
||||
当前专项“网站级规则与配置界面改造”的模型边界已纳入 [产品边界](../design/index.md),执行时仍按数据模型、接口、前端页面、迁移测试与文档联动的顺序推进。
|
||||
@@ -1,86 +0,0 @@
|
||||
# Agent Unified OpenResty Binary Control Scheme
|
||||
|
||||
# Agent 统一 OpenResty 二进制控制方案
|
||||
|
||||
## Summary
|
||||
|
||||
将 Agent 运行模型统一为“写入受管配置文件,然后调用 `openresty` 二进制执行 `-t`、reload、start/restart”。Docker 部署不再由 Agent 控制另一个 OpenResty 容器,而是提供独立的 `ghcr.io/rain-kl/openflare-agent` 镜像;该镜像基于 `openresty/openresty`,内置 Agent 控制器和 OpenResty 二进制。
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Agent runtime:
|
||||
- 移除生产路径中的 DockerExecutor / Docker 容器管理逻辑。
|
||||
- `openresty_path` 未配置时默认使用 `openresty`。
|
||||
- 二进制执行统一带 `-c <main_config_path>`,避免误读 OpenResty 默认配置。
|
||||
- apply 流程为:备份 -> 写入文件 -> `openresty -t -c ...` -> reload;若 reload 表明未运行,则 start。
|
||||
- restart 使用 `openresty -c ... -s quit` 后再 `openresty -c ...` 启动,保留缺失 PID 的容错。
|
||||
|
||||
- 配置与文件职责:
|
||||
- 保留旧字段 `openresty_container_name`、`openresty_docker_image`、`docker_binary` 的解析兼容,但标记废弃且不再参与控制逻辑。
|
||||
- 新增 `access_log_path`,默认 `data_dir/var/log/openflare/access.log`,不再把访问日志放进 `conf.d`。
|
||||
- 新增 `runtime_config_dir`,默认 `data_dir/etc/openflare`,`pow_config.json` 写入这里。
|
||||
- `cert_dir` 只写证书/密钥文件;`lua_dir` 只写 Lua 代码与静态资源。
|
||||
- 支持文件写入前先拆分:证书文件进入 `cert_dir`,`pow_config.json` 进入 `runtime_config_dir`。
|
||||
|
||||
- Docker Agent 镜像:
|
||||
- 新增 `openflare_agent/Dockerfile`,运行镜像基于 `openresty/openresty:alpine`。
|
||||
- 默认 `OPENFLARE_OPENRESTY_PATH=openresty`、`OPENFLARE_DATA_DIR=/data`。
|
||||
- 暴露 `80`、`443`、`18081`。
|
||||
- 支持挂载 `/etc/openflare/agent.json`,也支持环境变量配置。
|
||||
- CI 发布独立多架构镜像:`ghcr.io/rain-kl/openflare-agent:<version>` 和 `latest`。
|
||||
|
||||
- Agent 配置入口:
|
||||
- 保留 `-config` + `agent.json`。
|
||||
- 新增环境变量覆盖/兜底:`OPENFLARE_SERVER_URL`、`OPENFLARE_AGENT_TOKEN`、`OPENFLARE_DISCOVERY_TOKEN`、`OPENFLARE_NODE_NAME`、`OPENFLARE_NODE_IP`、`OPENFLARE_DATA_DIR`、`OPENFLARE_OPENRESTY_PATH`、`OPENFLARE_HEARTBEAT_INTERVAL`、`OPENFLARE_REQUEST_TIMEOUT`、`OPENFLARE_OPENRESTY_OBSERVABILITY_PORT`。
|
||||
- 若配置文件不存在但环境变量足够,Agent 可直接启动;若两者都存在,环境变量覆盖文件值。
|
||||
|
||||
- 脚本与文档:
|
||||
- `install-agent.sh` 转为本地 OpenResty 部署脚本,增加 `--openresty-path`,未传时自动查找 `openresty`。
|
||||
- `uninstall-agent.sh` 只卸载 Agent 本身,不再删除 Docker OpenResty 容器或镜像。
|
||||
- 更新架构、开发约束、部署说明、Agent 指南、配置项参考、README,以及英文镜像文档中的旧 Docker 控制说明。
|
||||
|
||||
## Public Interfaces
|
||||
|
||||
- 新增 Agent 配置字段:
|
||||
- `access_log_path`
|
||||
- `runtime_config_dir`
|
||||
|
||||
- 废弃但兼容读取:
|
||||
- `openresty_container_name`
|
||||
- `openresty_docker_image`
|
||||
- `docker_binary`
|
||||
|
||||
- 新增 Docker 镜像:
|
||||
- `ghcr.io/rain-kl/openflare-agent`
|
||||
|
||||
- Docker 运行方式示例目标:
|
||||
- 挂载配置文件:`-v ./agent.json:/etc/openflare/agent.json`
|
||||
- 或环境变量:`-e OPENFLARE_SERVER_URL=... -e OPENFLARE_AGENT_TOKEN=...`
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `openflare_agent/internal/config`:
|
||||
- 默认 `openresty_path` 为 `openresty`。
|
||||
- 旧 Docker 字段可读取但不影响 executor。
|
||||
- 环境变量可在无配置文件时启动,并可覆盖配置文件。
|
||||
- 新默认路径符合职责边界。
|
||||
|
||||
- `openflare_agent/internal/nginx`:
|
||||
- 二进制命令都包含 `-c <main_config_path>`。
|
||||
- apply 成功、reload 失败后回滚、未运行时 start fallback。
|
||||
- `pow_config.json` 不再写入 `cert_dir` 或 `lua_dir`。
|
||||
- stale `cert_dir/pow_config.json` 与 `lua_dir/pow_config.json` 会被清理。
|
||||
- access log 渲染到 `access_log_path`。
|
||||
- checksum 仍能把主配置、路由配置、证书和 PoW 配置统一纳入比较。
|
||||
|
||||
- 集成回归:
|
||||
- `cd openflare_agent && GOCACHE=/tmp/openflare-go-cache go test ./...`
|
||||
- `cd openflare_server && GOCACHE=/tmp/openflare-go-cache go test ./...`
|
||||
- Dockerfile 构建 smoke test:构建 Agent 镜像并用 env-only 配置启动到可执行阶段。
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Docker Agent 镜像名固定为 `ghcr.io/rain-kl/openflare-agent`。
|
||||
- 旧 Docker 控制字段保留兼容,但不再作为受支持行为。
|
||||
- 本次不改 Server API、不改数据库模型、不引入远程命令能力。
|
||||
- OpenResty 主配置模板继续由 Server 生成;Agent 只负责本地路径替换、文件落盘和二进制控制。
|
||||
@@ -89,7 +89,7 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
|
||||
}
|
||||
```
|
||||
|
||||
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](../reference/configuration.md#agent-配置字段)。
|
||||
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](./configuration.md#agent-配置字段)。
|
||||
|
||||
## Docker 运行
|
||||
|
||||
@@ -80,8 +80,6 @@ go run . --port 3000 --log-dir ./logs
|
||||
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数,至少 1 天 | `30` |
|
||||
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
|
||||
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口 | `50` / `60` |
|
||||
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口 | `50` / `60` |
|
||||
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
|
||||
|
||||
说明:
|
||||
@@ -168,10 +166,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
||||
| `node_name` | 节点名称 | 否 | 自动使用主机名 |
|
||||
| `node_ip` | 节点 IP | 否 | 自动探测,优先通过第三方 API 获取真实出口公网 IP;失败时退回本机网卡探测 |
|
||||
| `openresty_path` | OpenResty 二进制路径 | 否 | `openresty` |
|
||||
| `openresty_container_name` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 |
|
||||
| `openresty_docker_image` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 |
|
||||
| `openresty_observability_port` | 本地观测与 OpenResty 健康检查端口 | 否 | `18081` |
|
||||
| `docker_binary` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 |
|
||||
| `data_dir` | Agent 数据目录 | 否 | 配置文件所在目录下的 `data` |
|
||||
| `main_config_path` | OpenResty 主配置写入路径 | 否 | `data_dir/etc/nginx/nginx.conf` |
|
||||
| `route_config_path` | 路由配置写入路径 | 否 | `data_dir/etc/nginx/conf.d/openflare_routes.conf` |
|
||||
@@ -201,6 +196,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
||||
* 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。
|
||||
* Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。
|
||||
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。
|
||||
* 在管理端开启“锁定节点 IP”后,Server 会保留管理端填写的节点 IP,后续 Agent 注册、HTTP 心跳或 WebSocket 状态上报不会覆盖该字段;关闭锁定后,下一次上报可重新回填。
|
||||
|
||||
## 常见配置组合
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
你会学到:OpenFlare 的推荐部署方式、Server 与 Agent 的运行要求、源码启动方式、联调步骤、升级与卸载入口。
|
||||
|
||||
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `SESSION_SECRET`。Agent 统一通过 OpenResty 二进制控制运行时;Docker 部署请直接使用内置 OpenResty 的 Agent 镜像。
|
||||
生产环境建议使用 PostgreSQL 作为 Server 数据库,并为 Server 显式配置 `SESSION_SECRET`。Agent 部署方式推荐为 Docker 部署(即直接使用内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本或手动本地运行。
|
||||
|
||||
## 部署拓扑
|
||||
|
||||
@@ -129,7 +129,37 @@ go run .
|
||||
go run . --port 3000 --log-dir ./logs
|
||||
```
|
||||
|
||||
## Agent 接入
|
||||
## Docker 运行 Agent(推荐)
|
||||
|
||||
Docker 部署是 Agent 推荐的部署方式。Docker 部署时直接运行 Agent 镜像,该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。未显式配置 `node_ip` 时,Agent 会优先通过第三方 API 获取真实出口 IP,避免把 Docker 网桥地址登记为节点 IP。
|
||||
|
||||
挂载配置文件:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v openflare-agent-data:/data \
|
||||
-v ./agent.json:/etc/openflare/agent.json:ro \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
使用环境变量:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## Agent 接入(脚本安装)
|
||||
|
||||
除了 Docker 部署外,也支持通过安装脚本将 Agent 部署在本地宿主机上。
|
||||
|
||||
使用 `discovery_token` 自动注册:
|
||||
|
||||
@@ -166,34 +196,6 @@ systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
## Docker 运行 Agent
|
||||
|
||||
Docker 部署时直接运行 Agent 镜像。该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。未显式配置 `node_ip` 时,Agent 会优先通过第三方 API 获取真实出口 IP,避免把 Docker 网桥地址登记为节点 IP。
|
||||
|
||||
挂载配置文件:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v openflare-agent-data:/data \
|
||||
-v ./agent.json:/etc/openflare/agent.json:ro \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
使用环境变量:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## 手动运行 Agent
|
||||
|
||||
源码运行:
|
||||
@@ -9,4 +9,4 @@
|
||||
| [配置项](./configuration.md) | Server 环境变量、命令行参数、运行时 Option 与 Agent 配置字段 |
|
||||
| [命令与脚本](./cli.md) | 常用启动、构建、测试、安装和卸载命令 |
|
||||
| [API 约定](./api.md) | 管理端 API 与 Agent API 的响应结构、鉴权和路径约定 |
|
||||
| [仓库结构](./repository.md) | `openflare_server`、`openflare_agent`、`openflare_server/web` 与 `docs` 的职责 |
|
||||
| [仓库结构](../design/repository.md) | `openflare_server`、`openflare_agent`、`openflare_server/web` 与 `docs` 的职责 |
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 仓库结构
|
||||
|
||||
你会学到:OpenFlare 仓库中 Server、Agent、前端、脚本和文档目录分别负责什么,以及贡献代码时应把逻辑放到哪一层。
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `openflare_server` | Gin + GORM + SQLite/PostgreSQL 单体控制面 |
|
||||
| `openflare_server/web` | Next.js 15 App Router 管理端前端,静态导出后由 Go Server 托管 |
|
||||
| `openflare_agent` | Go 单体 Agent,运行在节点侧 |
|
||||
| `scripts` | Agent 安装、卸载等辅助脚本 |
|
||||
| `docs` | VitePress 文档站、设计基线、开发规范、部署与配置文档 |
|
||||
|
||||
## Server 分层
|
||||
|
||||
| 目录 | 职责 |
|
||||
| --- | --- |
|
||||
| `controller/` | 参数解析、调用 service、返回响应 |
|
||||
| `service/` | 业务逻辑、校验、事务编排、配置渲染 |
|
||||
| `model/` | 模型定义、数据库版本与迁移 |
|
||||
| `router/` | 路由注册 |
|
||||
| `middleware/` | 认证、鉴权、限流等横切逻辑 |
|
||||
| `common/` | 配置、全局状态与初始化入口 |
|
||||
| `utils/` | 纯工具函数与通用 helper |
|
||||
|
||||
## Agent 模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `config` | 配置读取与默认值 |
|
||||
| `heartbeat` | 心跳与版本摘要判断 |
|
||||
| `sync` | 配置拉取与应用编排 |
|
||||
| `nginx` / `openresty` | OpenResty 文件写入、校验、reload、启动与回滚 |
|
||||
| `state` | 本地状态与观测补报缓冲 |
|
||||
| `httpclient` | Server 通信 |
|
||||
| `protocol` | Agent API 协议类型 |
|
||||
| `internal/updater` | Agent 自更新 |
|
||||
|
||||
## Frontend 分层
|
||||
|
||||
| 目录 | 职责 |
|
||||
| --- | --- |
|
||||
| `app/` | 路由、布局、页面组装 |
|
||||
| `features/` | 按业务域组织模块 |
|
||||
| `components/` | 跨 feature 复用组件 |
|
||||
| `lib/` | 请求客户端、环境变量、工具函数、常量 |
|
||||
| `store/` | 少量跨页面 UI 状态 |
|
||||
| `types/` | 共享类型定义 |
|
||||
@@ -71,7 +71,7 @@ func main() {
|
||||
LuaDir: cfg.LuaDir,
|
||||
NginxLuaDir: cfg.OpenrestyLuaDir,
|
||||
RuntimeConfigDir: cfg.RuntimeConfigDir,
|
||||
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyPath, cfg.OpenrestyObservabilityPort),
|
||||
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort),
|
||||
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
|
||||
OpenrestyResolverDirective: "",
|
||||
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"openflare/utils"
|
||||
"openflare/utils/geoip"
|
||||
"openflare/utils/geoip/iputil"
|
||||
"os"
|
||||
@@ -47,9 +48,6 @@ type Config struct {
|
||||
NginxVersion string `json:"-"`
|
||||
OpenrestyPath string `json:"openresty_path"`
|
||||
OpenrestyResolvers []string `json:"openresty_resolvers,omitempty"`
|
||||
OpenrestyContainerName string `json:"openresty_container_name,omitempty"`
|
||||
OpenrestyDockerImage string `json:"openresty_docker_image,omitempty"`
|
||||
DockerBinary string `json:"docker_binary,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
MainConfigPath string `json:"main_config_path"`
|
||||
RouteConfigPath string `json:"route_config_path"`
|
||||
@@ -79,9 +77,6 @@ type configFile struct {
|
||||
NodeIP string `json:"node_ip"`
|
||||
OpenrestyPath string `json:"openresty_path"`
|
||||
OpenrestyResolvers []string `json:"openresty_resolvers"`
|
||||
OpenrestyContainerName string `json:"openresty_container_name"`
|
||||
OpenrestyDockerImage string `json:"openresty_docker_image"`
|
||||
DockerBinary string `json:"docker_binary"`
|
||||
DataDir string `json:"data_dir"`
|
||||
MainConfigPath string `json:"main_config_path"`
|
||||
RouteConfigPath string `json:"route_config_path"`
|
||||
@@ -124,9 +119,6 @@ func Load(path string) (*Config, error) {
|
||||
NodeIP: file.NodeIP,
|
||||
OpenrestyPath: file.OpenrestyPath,
|
||||
OpenrestyResolvers: append([]string{}, file.OpenrestyResolvers...),
|
||||
OpenrestyContainerName: file.OpenrestyContainerName,
|
||||
OpenrestyDockerImage: file.OpenrestyDockerImage,
|
||||
DockerBinary: file.DockerBinary,
|
||||
DataDir: file.DataDir,
|
||||
MainConfigPath: file.MainConfigPath,
|
||||
RouteConfigPath: file.RouteConfigPath,
|
||||
@@ -158,7 +150,7 @@ func Load(path string) (*Config, error) {
|
||||
func applyDefaults(cfg *Config, baseDir string) {
|
||||
baseDir = filepath.Clean(baseDir)
|
||||
cfg.AgentVersion = AgentVersion
|
||||
cfg.OpenrestyResolvers = normalizeResolverList(cfg.OpenrestyResolvers)
|
||||
cfg.OpenrestyResolvers = utils.UniqueAndCleanStringSlice(cfg.OpenrestyResolvers)
|
||||
if cfg.OpenrestyPath == "" {
|
||||
cfg.OpenrestyPath = "openresty"
|
||||
}
|
||||
@@ -229,41 +221,24 @@ func normalizeManagedPaths(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if usesSlashPath(cfg.DataDir) {
|
||||
cfg.DataDir = filepath.ToSlash(cfg.DataDir)
|
||||
paths := []*string{
|
||||
&cfg.DataDir,
|
||||
&cfg.MainConfigPath,
|
||||
&cfg.RouteConfigPath,
|
||||
&cfg.AccessLogPath,
|
||||
&cfg.CertDir,
|
||||
&cfg.OpenrestyCertDir,
|
||||
&cfg.LuaDir,
|
||||
&cfg.OpenrestyLuaDir,
|
||||
&cfg.RuntimeConfigDir,
|
||||
&cfg.StatePath,
|
||||
&cfg.ObservabilityBufferPath,
|
||||
&cfg.MMDBPath,
|
||||
}
|
||||
if usesSlashPath(cfg.MainConfigPath) {
|
||||
cfg.MainConfigPath = filepath.ToSlash(cfg.MainConfigPath)
|
||||
}
|
||||
if usesSlashPath(cfg.RouteConfigPath) {
|
||||
cfg.RouteConfigPath = filepath.ToSlash(cfg.RouteConfigPath)
|
||||
}
|
||||
if usesSlashPath(cfg.AccessLogPath) {
|
||||
cfg.AccessLogPath = filepath.ToSlash(cfg.AccessLogPath)
|
||||
}
|
||||
if usesSlashPath(cfg.CertDir) {
|
||||
cfg.CertDir = filepath.ToSlash(cfg.CertDir)
|
||||
}
|
||||
if usesSlashPath(cfg.OpenrestyCertDir) {
|
||||
cfg.OpenrestyCertDir = filepath.ToSlash(cfg.OpenrestyCertDir)
|
||||
}
|
||||
if usesSlashPath(cfg.LuaDir) {
|
||||
cfg.LuaDir = filepath.ToSlash(cfg.LuaDir)
|
||||
}
|
||||
if usesSlashPath(cfg.OpenrestyLuaDir) {
|
||||
cfg.OpenrestyLuaDir = filepath.ToSlash(cfg.OpenrestyLuaDir)
|
||||
}
|
||||
if usesSlashPath(cfg.RuntimeConfigDir) {
|
||||
cfg.RuntimeConfigDir = filepath.ToSlash(cfg.RuntimeConfigDir)
|
||||
}
|
||||
if usesSlashPath(cfg.StatePath) {
|
||||
cfg.StatePath = filepath.ToSlash(cfg.StatePath)
|
||||
}
|
||||
if usesSlashPath(cfg.ObservabilityBufferPath) {
|
||||
cfg.ObservabilityBufferPath = filepath.ToSlash(cfg.ObservabilityBufferPath)
|
||||
}
|
||||
if usesSlashPath(cfg.MMDBPath) {
|
||||
cfg.MMDBPath = filepath.ToSlash(cfg.MMDBPath)
|
||||
for _, p := range paths {
|
||||
if usesSlashPath(*p) {
|
||||
*p = filepath.ToSlash(*p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,38 +389,6 @@ func detectHostname() string {
|
||||
return strings.TrimSpace(host)
|
||||
}
|
||||
|
||||
func normalizeResolverList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func detectNodeIP() string {
|
||||
if ip := detectOutboundNodeIP(); ip != "" {
|
||||
return ip
|
||||
|
||||
@@ -161,41 +161,6 @@ func TestLoadNormalizesExplicitResolvers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKeepsDeprecatedDockerFieldsForCompatibility(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
payload := map[string]any{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "token",
|
||||
"node_name": "edge-01",
|
||||
"node_ip": "10.0.0.8",
|
||||
"openresty_container_name": "openflare-openresty",
|
||||
"openresty_docker_image": "openresty/openresty:alpine",
|
||||
"docker_binary": "docker",
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal config: %v", err)
|
||||
}
|
||||
if err = os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if cfg.OpenrestyContainerName != "openflare-openresty" {
|
||||
t.Fatalf("unexpected container name: %s", cfg.OpenrestyContainerName)
|
||||
}
|
||||
if cfg.OpenrestyDockerImage != "openresty/openresty:alpine" {
|
||||
t.Fatalf("unexpected image: %s", cfg.OpenrestyDockerImage)
|
||||
}
|
||||
if cfg.DockerBinary != "docker" {
|
||||
t.Fatalf("unexpected docker binary: %s", cfg.DockerBinary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "agent.json")
|
||||
@@ -462,15 +427,6 @@ func TestSavePersistsMillisecondsAndOmitsRuntimeVersions(t *testing.T) {
|
||||
if _, ok := decoded["nginx_path"]; ok {
|
||||
t.Fatal("legacy nginx_path should not be persisted")
|
||||
}
|
||||
if _, ok := decoded["openresty_container_name"]; ok {
|
||||
t.Fatal("deprecated openresty_container_name should not be persisted by default")
|
||||
}
|
||||
if _, ok := decoded["openresty_docker_image"]; ok {
|
||||
t.Fatal("deprecated openresty_docker_image should not be persisted by default")
|
||||
}
|
||||
if _, ok := decoded["docker_binary"]; ok {
|
||||
t.Fatal("deprecated docker_binary should not be persisted by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialAuthToken(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -107,7 +108,12 @@ func (c *Client) do(req *http.Request, target any) error {
|
||||
slog.Error("http request failed", "method", req.Method, "path", req.URL.Path, "error", err)
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
}
|
||||
}(res.Body)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
slog.Warn("http request returned non-200", "method", req.Method, "path", req.URL.Path, "status", res.Status)
|
||||
return errors.New(res.Status)
|
||||
|
||||
@@ -19,18 +19,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"openflare/utils"
|
||||
openrestyrender "openflare/utils/render/openresty"
|
||||
|
||||
"openflare-agent/internal/protocol"
|
||||
)
|
||||
|
||||
const CertDirPlaceholder = "__OPENFLARE_CERT_DIR__"
|
||||
const RouteConfigPlaceholder = "__OPENFLARE_ROUTE_CONFIG__"
|
||||
const AccessLogPlaceholder = "__OPENFLARE_ACCESS_LOG__"
|
||||
const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
|
||||
const RuntimeConfigDirPlaceholder = "__OPENFLARE_RUNTIME_CONFIG_DIR__"
|
||||
const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
|
||||
const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
|
||||
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
|
||||
const PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
||||
|
||||
type Executor interface {
|
||||
Test(ctx context.Context) error
|
||||
@@ -106,7 +102,7 @@ func (e *PathExecutor) Reload(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *PathExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
func (e *PathExecutor) EnsureRuntime(ctx context.Context, _ bool) error {
|
||||
if err := e.Test(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -223,6 +219,9 @@ func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, suppor
|
||||
if err := m.writeWAFConfig(supportFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.writeSourceConfig(supportFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.ensureMimeTypes(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -390,30 +389,32 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
}
|
||||
normalizedMain := string(mainData)
|
||||
if includePath := m.routeConfigIncludePath(); includePath != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, RouteConfigPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, includePath, openrestyrender.RouteConfigPlaceholder)
|
||||
}
|
||||
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, AccessLogPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, accessLogPath, openrestyrender.AccessLogPlaceholder)
|
||||
errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log")
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, filepath.ToSlash(errorLogPath), openrestyrender.ErrorLogPlaceholder)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, LuaDirPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, luaDir, openrestyrender.LuaDirPlaceholder)
|
||||
}
|
||||
if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, listen, ObservabilityListenPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, listen, openrestyrender.ObservabilityListenPlaceholder)
|
||||
}
|
||||
if m.OpenrestyObservabilityPort > 0 {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), ObservabilityPortPlaceholder)
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, fmt.Sprintf("%d", m.OpenrestyObservabilityPort), openrestyrender.ObservabilityPortPlaceholder)
|
||||
}
|
||||
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
|
||||
normalizedMain = strings.ReplaceAll(normalizedMain, resolverDirective, ResolverDirectivePlaceholder)
|
||||
}
|
||||
normalizedRoute := string(data)
|
||||
if m.NginxCertDir != "" {
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", PowStaticDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, LuaDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", openrestyrender.PowStaticDirPlaceholder)
|
||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, openrestyrender.LuaDirPlaceholder)
|
||||
}
|
||||
files, err := m.readManagedSupportFiles()
|
||||
if err != nil {
|
||||
@@ -480,7 +481,7 @@ func parseNginxVersion(output string) string {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
var nginxVersionPattern = regexp.MustCompile(`(?im)(?:nginx|openresty) version:\s*(?:nginx|openresty)/([^\s]+)`)
|
||||
var nginxVersionPattern = regexp.MustCompile(`(?im)(?:nginx|openresty) version:\s*(?:nginx|openresty)/(\S+)`)
|
||||
|
||||
func isIgnorableOpenrestyStopError(output string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(output))
|
||||
@@ -507,6 +508,8 @@ type backupState struct {
|
||||
RouteData []byte
|
||||
Files []protocol.SupportFile
|
||||
PowConfig *protocol.SupportFile
|
||||
WAFConfig *protocol.SupportFile
|
||||
SourceConfig *protocol.SupportFile
|
||||
}
|
||||
|
||||
type managedFile struct {
|
||||
@@ -568,6 +571,16 @@ func (m *Manager) backup() (*backupState, error) {
|
||||
return nil, err
|
||||
}
|
||||
state.PowConfig = powConfig
|
||||
wafConfig, err := m.readRuntimeConfigFile("waf_config.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.WAFConfig = wafConfig
|
||||
sourceConfig, err := m.readRuntimeConfigFile(openrestyrender.SourceConfigFileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.SourceConfig = sourceConfig
|
||||
slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files))
|
||||
return state, nil
|
||||
}
|
||||
@@ -596,7 +609,13 @@ func (m *Manager) restore(state *backupState) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return m.restorePowConfig(state)
|
||||
if err := m.restoreRuntimeConfig(state.PowConfig, "pow_config.json"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.restoreRuntimeConfig(state.WAFConfig, "waf_config.json"); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.restoreRuntimeConfig(state.SourceConfig, openrestyrender.SourceConfigFileName)
|
||||
}
|
||||
|
||||
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
|
||||
@@ -652,10 +671,30 @@ func (m *Manager) writeWAFConfig(supportFiles []protocol.SupportFile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeSourceConfig(supportFiles []protocol.SupportFile) error {
|
||||
if m.RuntimeConfigDir == "" {
|
||||
return nil
|
||||
}
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, openrestyrender.SourceConfigFileName)
|
||||
for _, file := range supportFiles {
|
||||
if file.Path == openrestyrender.SourceConfigFileName {
|
||||
if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", openrestyrender.SourceConfigFileName, err)
|
||||
}
|
||||
slog.Info("wrote openresty source config", "path", configPath, "size", len(file.Content))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove %s: %w", openrestyrender.SourceConfigFileName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
|
||||
files := make([]managedFile, 0, len(certFiles))
|
||||
for _, file := range certFiles {
|
||||
if file.Path == "pow_config.json" || file.Path == "waf_config.json" {
|
||||
if file.Path == "pow_config.json" || file.Path == "waf_config.json" || file.Path == openrestyrender.SourceConfigFileName {
|
||||
continue
|
||||
}
|
||||
targetPath, err := m.certFileTargetPath(file.Path)
|
||||
@@ -720,10 +759,14 @@ func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
|
||||
return m.readRuntimeConfigFile("pow_config.json")
|
||||
}
|
||||
|
||||
func (m *Manager) readRuntimeConfigFile(name string) (*protocol.SupportFile, error) {
|
||||
if m.RuntimeConfigDir == "" {
|
||||
return nil, nil
|
||||
}
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json")
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, name)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -732,7 +775,7 @@ func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
|
||||
return nil, err
|
||||
}
|
||||
return &protocol.SupportFile{
|
||||
Path: "pow_config.json",
|
||||
Path: name,
|
||||
Content: string(data),
|
||||
}, nil
|
||||
}
|
||||
@@ -749,21 +792,28 @@ func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
|
||||
if powConfig != nil {
|
||||
files = append(files, *powConfig)
|
||||
}
|
||||
wafConfig, err := m.readRuntimeConfigFile("waf_config.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wafConfig != nil {
|
||||
files = append(files, *wafConfig)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (m *Manager) restorePowConfig(state *backupState) error {
|
||||
if state == nil || m.RuntimeConfigDir == "" {
|
||||
func (m *Manager) restoreRuntimeConfig(file *protocol.SupportFile, name string) error {
|
||||
if m.RuntimeConfigDir == "" {
|
||||
return nil
|
||||
}
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, "pow_config.json")
|
||||
if state.PowConfig == nil {
|
||||
configPath := filepath.Join(m.RuntimeConfigDir, name)
|
||||
if file == nil {
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(configPath, []byte(state.PowConfig.Content), 0o644)
|
||||
return os.WriteFile(configPath, []byte(file.Content), 0o644)
|
||||
}
|
||||
|
||||
func (m *Manager) writeSafeDefaultFallbackFiles() error {
|
||||
@@ -997,11 +1047,11 @@ func (m *Manager) ensureMimeTypes() error {
|
||||
func (m *Manager) renderRouteConfig(content string) string {
|
||||
rendered := content
|
||||
if m.NginxCertDir != "" {
|
||||
rendered = strings.ReplaceAll(rendered, CertDirPlaceholder, m.NginxCertDir)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir)
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
|
||||
rendered = strings.ReplaceAll(rendered, PowStaticDirPlaceholder, luaDir+"/pow/static")
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.PowStaticDirPlaceholder, luaDir+"/pow/static")
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
@@ -1009,19 +1059,21 @@ func (m *Manager) renderRouteConfig(content string) string {
|
||||
func (m *Manager) renderMainConfig(content string) string {
|
||||
rendered := content
|
||||
if includePath := m.routeConfigIncludePath(); includePath != "" {
|
||||
rendered = strings.ReplaceAll(rendered, RouteConfigPlaceholder, includePath)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.RouteConfigPlaceholder, includePath)
|
||||
}
|
||||
if accessLogPath := m.accessLogRuntimePath(); accessLogPath != "" {
|
||||
rendered = strings.ReplaceAll(rendered, AccessLogPlaceholder, accessLogPath)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.AccessLogPlaceholder, accessLogPath)
|
||||
errorLogPath := filepath.Join(filepath.Dir(accessLogPath), "error.log")
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorLogPlaceholder, filepath.ToSlash(errorLogPath))
|
||||
}
|
||||
if luaDir := m.luaRuntimePath(); luaDir != "" {
|
||||
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
||||
}
|
||||
if listen := strings.TrimSpace(m.OpenrestyObservabilityListen); listen != "" {
|
||||
rendered = strings.ReplaceAll(rendered, ObservabilityListenPlaceholder, listen)
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityListenPlaceholder, listen)
|
||||
}
|
||||
if m.OpenrestyObservabilityPort > 0 {
|
||||
rendered = strings.ReplaceAll(rendered, ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
|
||||
rendered = strings.ReplaceAll(rendered, openrestyrender.ObservabilityPortPlaceholder, fmt.Sprintf("%d", m.OpenrestyObservabilityPort))
|
||||
}
|
||||
if resolverDirective := strings.TrimSpace(m.OpenrestyResolverDirective); resolverDirective != "" {
|
||||
rendered = strings.ReplaceAll(rendered, ResolverDirectivePlaceholder, resolverDirective)
|
||||
@@ -1047,23 +1099,23 @@ func (m *Manager) managedWAFLuaFiles() []protocol.SupportFile {
|
||||
return files
|
||||
}
|
||||
|
||||
func ObservabilityListenAddress(openrestyPath string, port int) string {
|
||||
func ObservabilityListenAddress(port int) string {
|
||||
if port <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("127.0.0.1:%d", port)
|
||||
}
|
||||
|
||||
func ResolverDirective(openrestyPath string, explicitResolvers []string) string {
|
||||
resolvers := resolverAddresses(openrestyPath, explicitResolvers)
|
||||
func ResolverDirective(explicitResolvers []string) string {
|
||||
resolvers := resolverAddresses(explicitResolvers)
|
||||
if len(resolvers) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" resolver %s valid=30s ipv6=off;\n resolver_timeout 5s;\n", strings.Join(resolvers, " "))
|
||||
}
|
||||
|
||||
func resolverAddresses(openrestyPath string, explicitResolvers []string) []string {
|
||||
if resolvers := normalizeResolverAddresses(explicitResolvers); len(resolvers) > 0 {
|
||||
func resolverAddresses(explicitResolvers []string) []string {
|
||||
if resolvers := utils.UniqueAndCleanStringSlice(explicitResolvers); len(resolvers) > 0 {
|
||||
return resolvers
|
||||
}
|
||||
data, err := os.ReadFile("/etc/resolv.conf")
|
||||
@@ -1106,29 +1158,6 @@ func isUsableDockerResolver(addr string) bool {
|
||||
return !ip.IsLoopback() && !ip.IsUnspecified()
|
||||
}
|
||||
|
||||
func normalizeResolverAddresses(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
resolvers := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
addr := strings.TrimSpace(value)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[addr]; ok {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
resolvers = append(resolvers, addr)
|
||||
}
|
||||
if len(resolvers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return resolvers
|
||||
}
|
||||
|
||||
func RequiresRuntimeResolver(originURL string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(originURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
|
||||
@@ -392,7 +392,7 @@ func TestManagerCheckHealthFailsWhenStubStatusUnavailable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolverDirectiveUsesExplicitResolvers(t *testing.T) {
|
||||
got := ResolverDirective("", []string{"10.0.0.2", "1.1.1.1"})
|
||||
got := ResolverDirective([]string{"10.0.0.2", "1.1.1.1"})
|
||||
if !strings.Contains(got, "resolver 10.0.0.2 1.1.1.1") {
|
||||
t.Fatalf("expected explicit resolver directive, got %q", got)
|
||||
}
|
||||
@@ -916,10 +916,10 @@ func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestObservabilityListenAddress(t *testing.T) {
|
||||
if got := ObservabilityListenAddress("", 18081); got != "127.0.0.1:18081" {
|
||||
if got := ObservabilityListenAddress(18081); got != "127.0.0.1:18081" {
|
||||
t.Fatalf("unexpected default observability listen address: %s", got)
|
||||
}
|
||||
if got := ObservabilityListenAddress("/usr/local/openresty/nginx/sbin/openresty", 18081); got != "127.0.0.1:18081" {
|
||||
if got := ObservabilityListenAddress(18081); got != "127.0.0.1:18081" {
|
||||
t.Fatalf("unexpected path observability listen address: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package nginx
|
||||
import "openflare-agent/internal/protocol"
|
||||
|
||||
const (
|
||||
openRestyObservabilityWindowTTL = 7200
|
||||
openRestyObservabilityWindowSize = 60
|
||||
openRestyObservabilityWindowTTL = "7200"
|
||||
openRestyObservabilityWindowSize = "60"
|
||||
)
|
||||
|
||||
const openRestyObservabilityInitLua = `local dict = ngx.shared.openflare_observability
|
||||
@@ -25,9 +25,9 @@ if request_uri == "/openflare/observability" or request_uri == "/openflare/stub_
|
||||
return
|
||||
end
|
||||
|
||||
local ttl = ` + "7200" + `
|
||||
local ttl = ` + openRestyObservabilityWindowTTL + `
|
||||
local now = ngx.time()
|
||||
local window_size = ` + "60" + `
|
||||
local window_size = ` + openRestyObservabilityWindowSize + `
|
||||
local window_start = now - (now % window_size)
|
||||
|
||||
local function ensure_counter(key)
|
||||
@@ -109,7 +109,7 @@ if not dict then
|
||||
end
|
||||
|
||||
local now = ngx.time()
|
||||
local window_size = ` + "60" + `
|
||||
local window_size = ` + openRestyObservabilityWindowSize + `
|
||||
local window_start = now - (now % window_size)
|
||||
local current_window = tostring(window_start)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" then
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
@@ -170,7 +170,7 @@ const openRestyPowCheckLua = `local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" then
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,7 +50,7 @@ local function load_config()
|
||||
end
|
||||
|
||||
local function list_contains(items, value)
|
||||
if not items or not value or value == "" then
|
||||
if not items or type(items) ~= "table" or not value or value == "" then
|
||||
return false
|
||||
end
|
||||
for _, item in ipairs(items) do
|
||||
@@ -95,7 +95,7 @@ local function ipv4_in_cidr(ip, cidr)
|
||||
end
|
||||
|
||||
local function ip_matches(items, ip)
|
||||
if not items or not ip or ip == "" then
|
||||
if not items or type(items) ~= "table" or not ip or ip == "" then
|
||||
return false
|
||||
end
|
||||
for _, item in ipairs(items) do
|
||||
@@ -196,7 +196,7 @@ end
|
||||
|
||||
local country = nil
|
||||
for _, group in ipairs(groups) do
|
||||
if group.country_whitelist and #group.country_whitelist > 0 then
|
||||
if type(group.country_whitelist) == "table" and #group.country_whitelist > 0 then
|
||||
country = country or lookup_country(ip)
|
||||
if list_contains(group.country_whitelist, country) then
|
||||
return
|
||||
@@ -211,7 +211,7 @@ for _, group in ipairs(groups) do
|
||||
end
|
||||
|
||||
for _, group in ipairs(groups) do
|
||||
if group.country_blacklist and #group.country_blacklist > 0 then
|
||||
if type(group.country_blacklist) == "table" and #group.country_blacklist > 0 then
|
||||
country = country or lookup_country(ip)
|
||||
if list_contains(group.country_blacklist, country) then
|
||||
return exit_with_group(group)
|
||||
@@ -229,7 +229,7 @@ const openRestyWAFCheckLua = `local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
local base_dir = string.match(script_path, "^(.*)/waf/[^/]+%.lua$")
|
||||
if base_dir and base_dir ~= "" then
|
||||
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
|
||||
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,7 +40,7 @@ func BuildProfile(cfg *config.Config, stateStore *state.Store) *protocol.NodeSys
|
||||
return profile
|
||||
}
|
||||
|
||||
func BuildSnapshot(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeMetricSnapshot {
|
||||
func BuildSnapshot(cfg *config.Config, stateStore *state.Store, managed *ManagedOpenRestyMetrics) *protocol.NodeMetricSnapshot {
|
||||
now := time.Now().UTC()
|
||||
metric := &protocol.NodeMetricSnapshot{
|
||||
CapturedAtUnix: now.Unix(),
|
||||
|
||||
@@ -18,7 +18,7 @@ const openRestyStubStatusPath = "/openflare/stub_status"
|
||||
|
||||
var stubStatusActivePattern = regexp.MustCompile(`Active connections:\s+(\d+)`)
|
||||
|
||||
type managedOpenRestyMetrics struct {
|
||||
type ManagedOpenRestyMetrics struct {
|
||||
TrafficReport *protocol.NodeTrafficReport
|
||||
OpenrestyRxBytes int64
|
||||
OpenrestyTxBytes int64
|
||||
@@ -38,7 +38,7 @@ type openRestyObservabilityResponse struct {
|
||||
OpenrestyTxBytes int64 `json:"openresty_tx_bytes"`
|
||||
}
|
||||
|
||||
func CollectManagedOpenRestyMetrics(cfg *config.Config) *managedOpenRestyMetrics {
|
||||
func CollectManagedOpenRestyMetrics(cfg *config.Config) *ManagedOpenRestyMetrics {
|
||||
if cfg == nil || cfg.OpenrestyObservabilityPort <= 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func CollectManagedOpenRestyMetrics(cfg *config.Config) *managedOpenRestyMetrics
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &managedOpenRestyMetrics{
|
||||
result := &ManagedOpenRestyMetrics{
|
||||
TrafficReport: &protocol.NodeTrafficReport{
|
||||
WindowStartedAtUnix: observabilityResp.WindowStartedAtUnix,
|
||||
WindowEndedAtUnix: observabilityResp.WindowEndedAtUnix,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"openflare-agent/internal/config"
|
||||
"openflare-agent/internal/protocol"
|
||||
"openflare-agent/internal/state"
|
||||
@@ -26,7 +27,7 @@ type accessLogRecord struct {
|
||||
RequestLength int64 `json:"request_length"`
|
||||
}
|
||||
|
||||
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^\]]+)\]\s+"(?:\S+)\s+(\S+)(?:\s+[^"]*)?"\s+(\d{3})\s+\S+`)
|
||||
var combinedAccessLogPattern = regexp.MustCompile(`^(\S+)\s+\S+\s+\S+\s+\[([^]]+)]\s+"\S+\s+(\S+)(?:\s+[^"]*)?"\s+(\d{3})\s+\S+`)
|
||||
|
||||
type trafficAggregate struct {
|
||||
windowStartedAt time.Time
|
||||
@@ -41,12 +42,12 @@ type trafficAggregate struct {
|
||||
logs []protocol.NodeAccessLog
|
||||
}
|
||||
|
||||
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) *protocol.NodeTrafficReport {
|
||||
func BuildTrafficReport(cfg *config.Config, stateStore *state.Store, managed *ManagedOpenRestyMetrics) *protocol.NodeTrafficReport {
|
||||
report, _, _ := BuildTrafficObservability(cfg, stateStore, managed)
|
||||
return report
|
||||
}
|
||||
|
||||
func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, managed *managedOpenRestyMetrics) (*protocol.NodeTrafficReport, []protocol.NodeAccessLog, *managedOpenRestyMetrics) {
|
||||
func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, managed *ManagedOpenRestyMetrics) (*protocol.NodeTrafficReport, []protocol.NodeAccessLog, *ManagedOpenRestyMetrics) {
|
||||
if cfg == nil || stateStore == nil {
|
||||
if managed != nil && managed.TrafficReport != nil {
|
||||
return managed.TrafficReport, nil, managed
|
||||
@@ -55,7 +56,7 @@ func BuildTrafficObservability(cfg *config.Config, stateStore *state.Store, mana
|
||||
}
|
||||
|
||||
aggregate := readAccessLogDelta(cfg, stateStore)
|
||||
accessLogs := []protocol.NodeAccessLog{}
|
||||
var accessLogs []protocol.NodeAccessLog
|
||||
if aggregate != nil {
|
||||
accessLogs = aggregate.accessLogs()
|
||||
}
|
||||
@@ -87,7 +88,12 @@ func readAccessLogDelta(cfg *config.Config, stateStore *state.Store) *trafficAgg
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer file.Close()
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close access log file", "error", err)
|
||||
}
|
||||
}(file)
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
@@ -270,7 +276,7 @@ func (aggregate *trafficAggregate) accessLogs() []protocol.NodeAccessLog {
|
||||
return append([]protocol.NodeAccessLog(nil), aggregate.logs...)
|
||||
}
|
||||
|
||||
func (aggregate *trafficAggregate) managedMetrics() *managedOpenRestyMetrics {
|
||||
func (aggregate *trafficAggregate) managedMetrics() *ManagedOpenRestyMetrics {
|
||||
if aggregate == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -278,7 +284,7 @@ func (aggregate *trafficAggregate) managedMetrics() *managedOpenRestyMetrics {
|
||||
if report == nil && aggregate.openrestyRxBytes <= 0 && aggregate.openrestyTxBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &managedOpenRestyMetrics{
|
||||
return &ManagedOpenRestyMetrics{
|
||||
TrafficReport: report,
|
||||
OpenrestyRxBytes: aggregate.openrestyRxBytes,
|
||||
OpenrestyTxBytes: aggregate.openrestyTxBytes,
|
||||
|
||||
@@ -167,7 +167,7 @@ func TestBuildTrafficReportParsesCombinedAccessLog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildTrafficReportReturnsManagedWindowEvenWhenRequestCountZero(t *testing.T) {
|
||||
report := BuildTrafficReport(nil, nil, &managedOpenRestyMetrics{
|
||||
report := BuildTrafficReport(nil, nil, &ManagedOpenRestyMetrics{
|
||||
TrafficReport: &protocol.NodeTrafficReport{
|
||||
WindowStartedAtUnix: 1710403200,
|
||||
WindowEndedAtUnix: 1710403260,
|
||||
|
||||
@@ -165,13 +165,11 @@ type ApplyLogPayload struct {
|
||||
}
|
||||
|
||||
type ActiveConfigResponse struct {
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
MainConfig string `json:"main_config"`
|
||||
RouteConfig string `json:"route_config"`
|
||||
RenderedConfig string `json:"rendered_config"`
|
||||
SupportFiles []SupportFile `json:"support_files"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
SourceConfigJSON string `json:"source_config_json"`
|
||||
SupportFiles []SupportFile `json:"support_files"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type ActiveConfigMeta struct {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -19,3 +22,129 @@ func TestEnsureNodeIDPersists(t *testing.T) {
|
||||
t.Fatal("expected node id to persist across calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Load_NonExistentFile(t *testing.T) {
|
||||
// Loading from a non-existent path should succeed and return an empty Snapshot
|
||||
tempFile := filepath.Join(t.TempDir(), "nonexistent.json")
|
||||
store := NewStore(tempFile)
|
||||
|
||||
snap, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected Load to succeed for non-existent file, got err: %v", err)
|
||||
}
|
||||
if snap == nil {
|
||||
t.Fatal("expected non-nil snapshot")
|
||||
}
|
||||
if snap.NodeID != "" || snap.CurrentVersion != "" {
|
||||
t.Errorf("expected empty snapshot, got: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Load_EmptyFile(t *testing.T) {
|
||||
// Loading from an empty file should succeed and return an empty Snapshot
|
||||
tempFile := filepath.Join(t.TempDir(), "empty.json")
|
||||
if err := os.WriteFile(tempFile, []byte(""), 0644); err != nil {
|
||||
t.Fatalf("failed to create empty file: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(tempFile)
|
||||
snap, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected Load to succeed for empty file, got err: %v", err)
|
||||
}
|
||||
if snap == nil {
|
||||
t.Fatal("expected non-nil snapshot")
|
||||
}
|
||||
if snap.NodeID != "" {
|
||||
t.Errorf("expected empty snapshot, got: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Load_InvalidJSON(t *testing.T) {
|
||||
// Loading from a corrupted file with invalid JSON should fail with parsing error
|
||||
tempFile := filepath.Join(t.TempDir(), "corrupted.json")
|
||||
if err := os.WriteFile(tempFile, []byte("{invalid-json"), 0644); err != nil {
|
||||
t.Fatalf("failed to create corrupted file: %v", err)
|
||||
}
|
||||
|
||||
store := NewStore(tempFile)
|
||||
_, err := store.Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected Load to fail for corrupted JSON file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SaveAndLoad(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "state.json")
|
||||
store := NewStore(tempFile)
|
||||
|
||||
original := &Snapshot{
|
||||
NodeID: "node-test-123",
|
||||
CurrentVersion: "20260531-001",
|
||||
CurrentChecksum: "chk-active-xyz",
|
||||
BlockedVersion: "20260531-002",
|
||||
BlockedChecksum: "chk-blocked-abc",
|
||||
BlockedReason: "invalid upstream domain name",
|
||||
LastError: "configuration reload timeout",
|
||||
OpenrestyStatus: "unhealthy",
|
||||
}
|
||||
|
||||
if err := store.Save(original); err != nil {
|
||||
t.Fatalf("expected Save to succeed, got: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("expected Load to succeed, got: %v", err)
|
||||
}
|
||||
|
||||
if loaded.NodeID != original.NodeID ||
|
||||
loaded.CurrentVersion != original.CurrentVersion ||
|
||||
loaded.CurrentChecksum != original.CurrentChecksum ||
|
||||
loaded.BlockedVersion != original.BlockedVersion ||
|
||||
loaded.BlockedChecksum != original.BlockedChecksum ||
|
||||
loaded.BlockedReason != original.BlockedReason ||
|
||||
loaded.LastError != original.LastError ||
|
||||
loaded.OpenrestyStatus != original.OpenrestyStatus {
|
||||
t.Errorf("loaded snapshot does not match original: %+v vs %+v", loaded, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_ConcurrencySafety(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "state.json")
|
||||
store := NewStore(tempFile)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
workers := 20
|
||||
iterations := 50
|
||||
|
||||
// Run concurrent writers and readers
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
// Concurrently save
|
||||
snap := &Snapshot{
|
||||
NodeID: fmt.Sprintf("node-%d", workerID),
|
||||
CurrentVersion: fmt.Sprintf("v-%d", j),
|
||||
}
|
||||
if err := store.Save(snap); err != nil {
|
||||
t.Errorf("Save failed under concurrency: %v", err)
|
||||
}
|
||||
|
||||
// Concurrently load
|
||||
if _, err := store.Load(); err != nil {
|
||||
t.Errorf("Load failed under concurrency: %v", err)
|
||||
}
|
||||
|
||||
// Concurrently ensure ID
|
||||
if _, err := store.EnsureNodeID(); err != nil {
|
||||
t.Errorf("EnsureNodeID failed under concurrency: %v", err)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
openrestyrender "openflare/utils/render/openresty"
|
||||
"strings"
|
||||
|
||||
"openflare-agent/internal/nginx"
|
||||
@@ -91,20 +93,16 @@ func (s *Service) sync(ctx context.Context, startup bool, target *protocol.Activ
|
||||
}
|
||||
|
||||
if currentChecksum == target.Checksum {
|
||||
if startup {
|
||||
config, fetchErr := s.client.GetActiveConfig(ctx)
|
||||
if fetchErr != nil {
|
||||
slog.Error("fetch active config failed", "mode", mode, "error", fetchErr)
|
||||
return fetchErr
|
||||
}
|
||||
return s.applyIfNeeded(ctx, mode, startup, snapshot, currentChecksum, target, config)
|
||||
}
|
||||
slog.Debug("local openresty config already up to date", "mode", mode, "version", target.Version)
|
||||
shouldReport := shouldReportNoopApply(snapshot, target.Version, target.Checksum)
|
||||
if startup {
|
||||
slog.Debug("ensuring openresty runtime on startup", "version", target.Version)
|
||||
if err = s.nginxManager.EnsureRuntime(ctx, true); err != nil {
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = err.Error()
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
return err
|
||||
}
|
||||
slog.Debug("openresty runtime ensured on startup", "version", target.Version)
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = ""
|
||||
}
|
||||
if shouldReport {
|
||||
if err = s.reportNoopApply(ctx, snapshot.NodeID, target.Version, target.Checksum, "", "", 0); err != nil {
|
||||
return err
|
||||
@@ -152,31 +150,28 @@ func (s *Service) ForceSyncOnce(ctx context.Context, target *protocol.ActiveConf
|
||||
clearBlockedTarget(snapshot)
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
}
|
||||
return s.SyncOnce(ctx, target)
|
||||
currentChecksum, err := s.nginxManager.CurrentChecksum()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
config, err := s.client.GetActiveConfig(ctx)
|
||||
if err != nil {
|
||||
slog.Error("fetch active config failed", "mode", "force", "error", err)
|
||||
return err
|
||||
}
|
||||
return s.applyIfNeeded(ctx, "force", true, snapshot, currentChecksum, target, config)
|
||||
}
|
||||
|
||||
func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta, config *protocol.ActiveConfigResponse) error {
|
||||
if currentChecksum == config.Checksum {
|
||||
if currentChecksum == config.Checksum && !startup {
|
||||
slog.Debug("local openresty config already up to date", "mode", mode, "version", config.Version)
|
||||
shouldReport := shouldReportNoopApply(snapshot, config.Version, config.Checksum)
|
||||
if startup {
|
||||
slog.Debug("ensuring openresty runtime on startup", "version", config.Version)
|
||||
if err := s.nginxManager.EnsureRuntime(ctx, true); err != nil {
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy
|
||||
snapshot.OpenrestyMessage = err.Error()
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
return err
|
||||
}
|
||||
slog.Debug("openresty runtime ensured on startup", "version", config.Version)
|
||||
snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy
|
||||
snapshot.OpenrestyMessage = ""
|
||||
}
|
||||
if shouldReport {
|
||||
routeConfig := config.RouteConfig
|
||||
if routeConfig == "" {
|
||||
routeConfig = config.RenderedConfig
|
||||
rendered, renderErr := renderActiveConfig(config)
|
||||
if renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
if err := s.reportNoopApply(ctx, snapshot.NodeID, config.Version, config.Checksum, checksumString(config.MainConfig), checksumString(routeConfig), len(config.SupportFiles)); err != nil {
|
||||
if err := s.reportNoopApply(ctx, snapshot.NodeID, config.Version, config.Checksum, checksumString(rendered.mainConfig), checksumString(rendered.routeConfig), len(rendered.supportFiles)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -207,14 +202,14 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
slog.Debug("skipping apply because state already records target version/checksum", "version", config.Version, "checksum", config.Checksum)
|
||||
return s.stateStore.Save(snapshot)
|
||||
}
|
||||
routeConfig := config.RouteConfig
|
||||
if routeConfig == "" {
|
||||
routeConfig = config.RenderedConfig
|
||||
rendered, err := renderActiveConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mainConfigChecksum := checksumString(config.MainConfig)
|
||||
routeConfigChecksum := checksumString(routeConfig)
|
||||
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)
|
||||
outcome := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles)
|
||||
outcome := s.nginxManager.Apply(ctx, rendered.mainConfig, rendered.routeConfig, rendered.supportFiles)
|
||||
message := strings.TrimSpace(outcome.Message)
|
||||
if outcome.Status == "" {
|
||||
outcome.Status = nginx.ApplyStatusFatal
|
||||
@@ -269,7 +264,7 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
Checksum: config.Checksum,
|
||||
MainConfigChecksum: mainConfigChecksum,
|
||||
RouteConfigChecksum: routeConfigChecksum,
|
||||
SupportFileCount: len(config.SupportFiles),
|
||||
SupportFileCount: len(rendered.supportFiles),
|
||||
}); err != nil {
|
||||
slog.Error("report apply log failed", "version", config.Version, "result", reportResult, "error", err)
|
||||
return err
|
||||
@@ -282,6 +277,55 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
||||
return nil
|
||||
}
|
||||
|
||||
type renderedActiveConfig struct {
|
||||
mainConfig string
|
||||
routeConfig string
|
||||
supportFiles []protocol.SupportFile
|
||||
}
|
||||
|
||||
func renderActiveConfig(config *protocol.ActiveConfigResponse) (*renderedActiveConfig, error) {
|
||||
if config == nil {
|
||||
return nil, errors.New("active config is nil")
|
||||
}
|
||||
sourceJSON := strings.TrimSpace(config.SourceConfigJSON)
|
||||
if sourceJSON == "" {
|
||||
return nil, errors.New("active config source_config_json is empty")
|
||||
}
|
||||
rendered, err := openrestyrender.RenderJSON(sourceJSON, toOpenRestySupportFiles(config.SupportFiles))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := fromOpenRestySupportFiles(rendered.SupportFiles)
|
||||
files = append(files, protocol.SupportFile{Path: openrestyrender.SourceConfigFileName, Content: sourceJSON})
|
||||
return &renderedActiveConfig{
|
||||
mainConfig: rendered.MainConfig,
|
||||
routeConfig: rendered.RouteConfig,
|
||||
supportFiles: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toOpenRestySupportFiles(files []protocol.SupportFile) []openrestyrender.SupportFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]openrestyrender.SupportFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
result = append(result, openrestyrender.SupportFile{Path: file.Path, Content: file.Content})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []protocol.SupportFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]protocol.SupportFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
result = append(result, protocol.SupportFile{Path: file.Path, Content: file.Content})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func shouldReportNoopApply(snapshot *state.Snapshot, version string, checksum string) bool {
|
||||
if snapshot == nil {
|
||||
return false
|
||||
|
||||
@@ -2,8 +2,10 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +38,10 @@ type fakeManager struct {
|
||||
applyFiles [][]protocol.SupportFile
|
||||
}
|
||||
|
||||
func testSourceConfigJSON(workerProcesses string, listen int) string {
|
||||
return fmt.Sprintf(`{"routes":[{"id":1,"site_name":"example","domain":"example.com","domains":["example.com"],"origin_url":"http://127.0.0.1:%d","upstreams":["http://127.0.0.1:%d"],"enabled":true}],"openresty_config":{"worker_processes":"%s","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":[]}}`, listen, listen, workerProcesses)
|
||||
}
|
||||
|
||||
func (f *fakeExecutor) Test(ctx context.Context) error {
|
||||
return f.testErr
|
||||
}
|
||||
@@ -93,13 +99,11 @@ func (m *fakeManager) CurrentChecksum() (string, error) {
|
||||
func TestSyncOnceSuccess(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-001",
|
||||
Checksum: "checksum-1",
|
||||
MainConfig: "worker_processes auto;",
|
||||
RouteConfig: "server { listen 80; }",
|
||||
RenderedConfig: "server { listen 80; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-001",
|
||||
Checksum: "checksum-1",
|
||||
SourceConfigJSON: testSourceConfigJSON("auto", 80),
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -132,7 +136,7 @@ func TestSyncOnceSuccess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read route config: %v", err)
|
||||
}
|
||||
if string(data) != "server { listen 80; }" {
|
||||
if !strings.Contains(string(data), "listen 80;") || !strings.Contains(string(data), "server_name example.com;") {
|
||||
t.Fatal("expected rendered config to be written to route file")
|
||||
}
|
||||
mainData, err := os.ReadFile(filepath.Join(filepath.Dir(routePath), "nginx.conf"))
|
||||
@@ -158,7 +162,7 @@ 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 != 1 {
|
||||
if client.reports[0].SupportFileCount != 4 {
|
||||
t.Fatalf("expected support file count to be reported, got %d", client.reports[0].SupportFileCount)
|
||||
}
|
||||
}
|
||||
@@ -166,13 +170,11 @@ func TestSyncOnceSuccess(t *testing.T) {
|
||||
func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-002",
|
||||
Checksum: "checksum-2",
|
||||
MainConfig: "worker_processes 2;",
|
||||
RouteConfig: "server { listen 81; }",
|
||||
RenderedConfig: "server { listen 81; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-002",
|
||||
Checksum: "checksum-2",
|
||||
SourceConfigJSON: testSourceConfigJSON("2", 81),
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -225,7 +227,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 != 1 {
|
||||
if client.reports[0].SupportFileCount != 4 {
|
||||
t.Fatalf("expected failed report to include support file count, got %d", client.reports[0].SupportFileCount)
|
||||
}
|
||||
}
|
||||
@@ -233,13 +235,11 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-002",
|
||||
Checksum: "checksum-2",
|
||||
MainConfig: "worker_processes 2;",
|
||||
RouteConfig: "server { listen 81; }",
|
||||
RenderedConfig: "server { listen 81; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-002",
|
||||
Checksum: "checksum-2",
|
||||
SourceConfigJSON: testSourceConfigJSON("2", 81),
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -294,13 +294,11 @@ func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
|
||||
func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-003",
|
||||
Checksum: "checksum-3",
|
||||
MainConfig: "worker_processes auto;",
|
||||
RouteConfig: "server { listen 82; }",
|
||||
RenderedConfig: "server { listen 82; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-003",
|
||||
Checksum: "checksum-3",
|
||||
SourceConfigJSON: testSourceConfigJSON("auto", 82),
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -320,14 +318,11 @@ func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("SyncOnStartup failed: %v", err)
|
||||
}
|
||||
if len(manager.ensureCalls) != 1 || !manager.ensureCalls[0] {
|
||||
t.Fatal("expected startup sync to recreate runtime")
|
||||
if len(manager.applyMainContents) != 1 {
|
||||
t.Fatal("expected startup sync to re-render and apply local config")
|
||||
}
|
||||
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultSuccess {
|
||||
t.Fatal("expected startup sync to report noop success when state is refreshed")
|
||||
}
|
||||
if client.reports[0].Message != "local config already matches active version; apply skipped" {
|
||||
t.Fatalf("unexpected noop apply message: %q", client.reports[0].Message)
|
||||
t.Fatal("expected startup sync to report apply success when state is refreshed")
|
||||
}
|
||||
snapshot, err := stateStore.Load()
|
||||
if err != nil {
|
||||
@@ -416,12 +411,10 @@ func TestSyncOnceDoesNotRepeatNoopReportWhenStateAlreadyMatches(t *testing.T) {
|
||||
func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-004",
|
||||
Checksum: "checksum-4",
|
||||
MainConfig: "worker_processes 4;",
|
||||
RouteConfig: "server { listen 83; }",
|
||||
RenderedConfig: "server { listen 83; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-004",
|
||||
Checksum: "checksum-4",
|
||||
SourceConfigJSON: testSourceConfigJSON("4", 83),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -435,7 +428,7 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
|
||||
|
||||
manager := &fakeManager{
|
||||
currentChecksum: "checksum-4",
|
||||
ensureErr: context.DeadlineExceeded,
|
||||
applyOutcome: nginx.ApplyOutcome{Status: nginx.ApplyStatusFatal, Message: context.DeadlineExceeded.Error()},
|
||||
}
|
||||
service := New(client, manager, stateStore)
|
||||
if err = service.SyncOnStartup(context.Background(), &protocol.ActiveConfigMeta{
|
||||
@@ -459,12 +452,10 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) {
|
||||
func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-006",
|
||||
Checksum: "checksum-6",
|
||||
MainConfig: "worker_processes 6;",
|
||||
RouteConfig: "server { listen 86; }",
|
||||
RenderedConfig: "server { listen 86; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-006",
|
||||
Checksum: "checksum-6",
|
||||
SourceConfigJSON: testSourceConfigJSON("6", 86),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -506,12 +497,10 @@ func TestSyncOnceSkipsPreviouslyBlockedVersion(t *testing.T) {
|
||||
func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
MainConfig: "worker_processes 7;",
|
||||
RouteConfig: "server { listen 87; }",
|
||||
RenderedConfig: "server { listen 87; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
SourceConfigJSON: testSourceConfigJSON("7", 87),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -565,12 +554,10 @@ func TestSyncOnStartupKeepsBlockedVersionSuppressedUntilNewTargetArrives(t *test
|
||||
func TestSyncOnStartupStartsFallbackWhenBlockedVersionHasNoLocalConfig(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
MainConfig: "worker_processes 7;",
|
||||
RouteConfig: "server { listen 87; }",
|
||||
RenderedConfig: "server { listen 87; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
SourceConfigJSON: testSourceConfigJSON("7", 87),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -625,12 +612,10 @@ func TestSyncOnStartupStartsFallbackWhenBlockedVersionHasNoLocalConfig(t *testin
|
||||
func TestSyncOnStartupStartsFallbackWhenResidualConfigCannotRecover(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
MainConfig: "worker_processes 7;",
|
||||
RouteConfig: "server { listen 87; }",
|
||||
RenderedConfig: "server { listen 87; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-007",
|
||||
Checksum: "checksum-7",
|
||||
SourceConfigJSON: testSourceConfigJSON("7", 87),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -679,12 +664,10 @@ func TestSyncOnStartupStartsFallbackWhenResidualConfigCannotRecover(t *testing.T
|
||||
func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-008",
|
||||
Checksum: "checksum-8",
|
||||
MainConfig: "worker_processes 8;",
|
||||
RouteConfig: "server { listen 88; }",
|
||||
RenderedConfig: "server { listen 88; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-008",
|
||||
Checksum: "checksum-8",
|
||||
SourceConfigJSON: testSourceConfigJSON("8", 88),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
@@ -732,12 +715,10 @@ func TestSyncOnceClearsBlockedTargetWhenNewVersionArrives(t *testing.T) {
|
||||
func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
config: protocol.ActiveConfigResponse{
|
||||
Version: "20260309-005",
|
||||
Checksum: "checksum-5",
|
||||
MainConfig: "worker_processes auto;",
|
||||
RouteConfig: "server { listen 84; }",
|
||||
RenderedConfig: "server { listen 84; }",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Version: "20260309-005",
|
||||
Checksum: "checksum-5",
|
||||
SourceConfigJSON: testSourceConfigJSON("auto", 84),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"openflare/utils"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -121,26 +121,7 @@ func (s *Service) getRelease(ctx context.Context, repo string, options agent.Upd
|
||||
|
||||
func (s *Service) getLatestStableRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github api returned %s", resp.Status)
|
||||
}
|
||||
|
||||
return decodeRelease(resp.Body)
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*githubRelease, error) {
|
||||
@@ -177,6 +158,10 @@ func (s *Service) getLatestPreviewRelease(ctx context.Context, repo string) (*gi
|
||||
|
||||
func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", repo, strings.TrimSpace(tag))
|
||||
return s.fetchReleaseFromURL(ctx, url)
|
||||
}
|
||||
|
||||
func (s *Service) fetchReleaseFromURL(ctx context.Context, url string) (*githubRelease, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -187,7 +172,12 @@ func (s *Service) getReleaseByTag(ctx context.Context, repo string, tag string)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("failed to close response body", "error", err)
|
||||
}
|
||||
}(resp.Body)
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
@@ -370,146 +360,6 @@ func buildReleaseCheckKey(options agent.UpdateOptions, remoteVersion string) str
|
||||
return channel + ":" + remoteVersion
|
||||
}
|
||||
|
||||
type versionInfo struct {
|
||||
valid bool
|
||||
isDev bool
|
||||
numbers []int
|
||||
prerelease []string
|
||||
}
|
||||
|
||||
func parseVersionInfo(version string) versionInfo {
|
||||
normalized := normalizeVersion(version)
|
||||
if normalized == "" || strings.EqualFold(normalized, "dev") {
|
||||
return versionInfo{isDev: strings.EqualFold(normalized, "dev")}
|
||||
}
|
||||
base := normalized
|
||||
prerelease := ""
|
||||
if index := strings.IndexRune(normalized, '-'); index >= 0 {
|
||||
base = normalized[:index]
|
||||
prerelease = normalized[index+1:]
|
||||
}
|
||||
segments := strings.Split(base, ".")
|
||||
parts := make([]int, 0, len(segments))
|
||||
for _, segment := range segments {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
parts = append(parts, 0)
|
||||
continue
|
||||
}
|
||||
numeric := strings.Builder{}
|
||||
for _, r := range segment {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
numeric.WriteRune(r)
|
||||
}
|
||||
if numeric.Len() == 0 {
|
||||
return versionInfo{}
|
||||
}
|
||||
value, err := strconv.Atoi(numeric.String())
|
||||
if err != nil {
|
||||
return versionInfo{}
|
||||
}
|
||||
parts = append(parts, value)
|
||||
}
|
||||
info := versionInfo{valid: len(parts) > 0, numbers: parts}
|
||||
if prerelease != "" {
|
||||
info.prerelease = splitPrereleaseIdentifiers(prerelease)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func splitPrereleaseIdentifiers(value string) []string {
|
||||
parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
|
||||
return r == '.' || r == '-'
|
||||
})
|
||||
filtered := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
filtered = append(filtered, part)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func compareVersions(local string, remote string) int {
|
||||
left := parseVersionInfo(local)
|
||||
right := parseVersionInfo(remote)
|
||||
if left.isDev {
|
||||
if right.valid {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if !left.valid || !right.valid {
|
||||
return 0
|
||||
}
|
||||
|
||||
maxLen := len(left.numbers)
|
||||
if len(right.numbers) > maxLen {
|
||||
maxLen = len(right.numbers)
|
||||
}
|
||||
for index := 0; index < maxLen; index++ {
|
||||
leftValue := 0
|
||||
rightValue := 0
|
||||
if index < len(left.numbers) {
|
||||
leftValue = left.numbers[index]
|
||||
}
|
||||
if index < len(right.numbers) {
|
||||
rightValue = right.numbers[index]
|
||||
}
|
||||
if leftValue < rightValue {
|
||||
return -1
|
||||
}
|
||||
if leftValue > rightValue {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if len(left.prerelease) == 0 && len(right.prerelease) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(left.prerelease) == 0 {
|
||||
return 1
|
||||
}
|
||||
if len(right.prerelease) == 0 {
|
||||
return -1
|
||||
}
|
||||
maxLen = len(left.prerelease)
|
||||
if len(right.prerelease) > maxLen {
|
||||
maxLen = len(right.prerelease)
|
||||
}
|
||||
for index := 0; index < maxLen; index++ {
|
||||
if index >= len(left.prerelease) {
|
||||
return -1
|
||||
}
|
||||
if index >= len(right.prerelease) {
|
||||
return 1
|
||||
}
|
||||
leftPart := left.prerelease[index]
|
||||
rightPart := right.prerelease[index]
|
||||
leftNumber, leftErr := strconv.Atoi(leftPart)
|
||||
rightNumber, rightErr := strconv.Atoi(rightPart)
|
||||
switch {
|
||||
case leftErr == nil && rightErr == nil:
|
||||
if leftNumber < rightNumber {
|
||||
return -1
|
||||
}
|
||||
if leftNumber > rightNumber {
|
||||
return 1
|
||||
}
|
||||
case leftErr == nil && rightErr != nil:
|
||||
return -1
|
||||
case leftErr != nil && rightErr == nil:
|
||||
return 1
|
||||
default:
|
||||
if leftPart < rightPart {
|
||||
return -1
|
||||
}
|
||||
if leftPart > rightPart {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return utils.CompareVersions(local, remote)
|
||||
}
|
||||
|
||||
@@ -140,7 +140,8 @@ func (conn *Connection) Receive() (protocol.WSMessage, error) {
|
||||
}
|
||||
err := websocket.JSON.Receive(conn.conn, &message)
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
slog.Debug("agent ws receive timeout waiting for server message", "timeout", conn.readTimeout)
|
||||
}
|
||||
return message, err
|
||||
|
||||
@@ -26,11 +26,10 @@ var OptionMapRWMutex sync.RWMutex
|
||||
var ItemsPerPage = 10
|
||||
|
||||
var PasswordLoginEnabled = true
|
||||
var PasswordRegisterEnabled = true
|
||||
var PasswordRegisterEnabled = false
|
||||
var EmailVerificationEnabled = false
|
||||
var GitHubOAuthEnabled = false
|
||||
var WeChatAuthEnabled = false
|
||||
var TurnstileCheckEnabled = false
|
||||
var RegisterEnabled = false
|
||||
|
||||
var SMTPServer = ""
|
||||
@@ -45,13 +44,12 @@ var WeChatServerAddress = ""
|
||||
var WeChatServerToken = ""
|
||||
var WeChatAccountQRCodeImageURL = ""
|
||||
|
||||
var TurnstileSiteKey = ""
|
||||
var TurnstileSecretKey = ""
|
||||
var AgentToken = ""
|
||||
var AgentDiscoveryToken = ""
|
||||
var NodeOfflineThreshold = 2 * time.Minute
|
||||
|
||||
// V3 operational settings (hot-reloadable via Option table)
|
||||
|
||||
var AgentHeartbeatInterval = 10000 // milliseconds
|
||||
var AgentWebsocketUpgradeEnabled = true
|
||||
var AgentUpdateRepo = "Rain-kl/OpenFlare"
|
||||
@@ -60,6 +58,7 @@ var DatabaseAutoCleanupEnabled = false
|
||||
var DatabaseAutoCleanupRetentionDays = 30
|
||||
|
||||
// V5 OpenResty performance settings (hot-reloadable via Option table)
|
||||
|
||||
var OpenRestyWorkerProcesses = "auto"
|
||||
var OpenRestyWorkerConnections = 4096
|
||||
var OpenRestyWorkerRlimitNofile = 65535
|
||||
@@ -98,6 +97,7 @@ var OpenRestyMainConfigTemplate = `# This file is generated by OpenFlare. Do not
|
||||
worker_processes {{OpenRestyWorkerProcesses}};
|
||||
worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}};
|
||||
pid logs/nginx.pid;
|
||||
error_log {{OpenRestyErrorLogPath}} warn;
|
||||
|
||||
events {
|
||||
worker_connections {{OpenRestyWorkerConnections}};
|
||||
@@ -140,13 +140,6 @@ const (
|
||||
RoleRootUser = 100
|
||||
)
|
||||
|
||||
var (
|
||||
FileUploadPermission = RoleGuestUser
|
||||
FileDownloadPermission = RoleGuestUser
|
||||
ImageUploadPermission = RoleGuestUser
|
||||
ImageDownloadPermission = RoleGuestUser
|
||||
)
|
||||
|
||||
// All duration's unit is seconds
|
||||
// Shouldn't larger then RateLimitKeyExpirationDuration
|
||||
var (
|
||||
@@ -156,12 +149,6 @@ var (
|
||||
GlobalWebRateLimitNum = 300
|
||||
GlobalWebRateLimitDuration int64 = 3 * 60
|
||||
|
||||
UploadRateLimitNum = 50
|
||||
UploadRateLimitDuration int64 = 60
|
||||
|
||||
DownloadRateLimitNum = 50
|
||||
DownloadRateLimitDuration int64 = 60
|
||||
|
||||
CriticalRateLimitNum = 100
|
||||
CriticalRateLimitDuration int64 = 20 * 60
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ package common
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type logLevel int
|
||||
|
||||
@@ -2,10 +2,11 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
var RDB *redis.Client
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
// GetAccessLogs godoc
|
||||
// @Summary List access logs
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Produce JSON
|
||||
// @Security BearerAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
@@ -35,7 +34,7 @@ func GetAccessLogs(c *gin.Context) {
|
||||
// GetFoldedAccessLogs godoc
|
||||
// @Summary List folded access logs
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Produce JSON
|
||||
// @Security BearerAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
@@ -62,7 +61,7 @@ func GetFoldedAccessLogs(c *gin.Context) {
|
||||
// GetFoldedAccessLogIPs godoc
|
||||
// @Summary List folded access log IP summaries
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Produce JSON
|
||||
// @Security BearerAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
@@ -99,7 +98,7 @@ func GetFoldedAccessLogIPs(c *gin.Context) {
|
||||
// GetAccessLogIPSummaries godoc
|
||||
// @Summary List access log IP summaries
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Produce JSON
|
||||
// @Security BearerAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string false "Remote address"
|
||||
@@ -130,7 +129,7 @@ func GetAccessLogIPSummaries(c *gin.Context) {
|
||||
// GetAccessLogIPTrend godoc
|
||||
// @Summary Get access log IP trend
|
||||
// @Tags AccessLogs
|
||||
// @Produce json
|
||||
// @Produce JSON
|
||||
// @Security BearerAuth
|
||||
// @Param node_id query string false "Node ID"
|
||||
// @Param remote_addr query string true "Remote address"
|
||||
@@ -157,19 +156,14 @@ func GetAccessLogIPTrend(c *gin.Context) {
|
||||
// CleanupAccessLogs godoc
|
||||
// @Summary Cleanup access logs by retention days
|
||||
// @Tags AccessLogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Accept JSON
|
||||
// @Produce JSON
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/access-logs/cleanup [post]
|
||||
func CleanupAccessLogs(c *gin.Context) {
|
||||
var input service.AccessLogCleanupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
"error": err.Error(),
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := service.CleanupAccessLogs(input)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetDefaultAcmeAccount godoc
|
||||
@@ -16,15 +16,8 @@ import (
|
||||
func GetDefaultAcmeAccount(c *gin.Context) {
|
||||
account, err := model.GetDefaultAcmeAccount()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": account,
|
||||
})
|
||||
respondSuccess(c, account)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,7 @@ import (
|
||||
// @Router /api/agent/nodes/register [post]
|
||||
func AgentRegister(c *gin.Context) {
|
||||
var payload service.AgentNodePayload
|
||||
if err := decodeJSONBody(c.Request.Body, &payload); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
|
||||
@@ -60,8 +59,7 @@ func AgentRegister(c *gin.Context) {
|
||||
// @Router /api/agent/nodes/heartbeat [post]
|
||||
func AgentHeartbeat(c *gin.Context) {
|
||||
var payload service.AgentNodePayload
|
||||
if err := decodeJSONBody(c.Request.Body, &payload); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
|
||||
@@ -111,8 +109,7 @@ func AgentGetActiveConfig(c *gin.Context) {
|
||||
// @Router /api/agent/apply-logs [post]
|
||||
func AgentReportApplyLog(c *gin.Context) {
|
||||
var payload service.ApplyLogPayload
|
||||
if err := decodeJSONBody(c.Request.Body, &payload); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -301,8 +298,7 @@ func GetApplyLogs(c *gin.Context) {
|
||||
// @Router /api/apply-logs/cleanup [post]
|
||||
func CleanupApplyLogs(c *gin.Context) {
|
||||
var input service.ApplyLogCleanupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
result, err := service.CleanupApplyLogs(input)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -18,17 +16,10 @@ import (
|
||||
func GetConfigVersions(c *gin.Context) {
|
||||
versions, err := service.ListConfigVersions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": versions,
|
||||
})
|
||||
respondSuccess(c, versions)
|
||||
}
|
||||
|
||||
// GetConfigVersion godoc
|
||||
@@ -41,27 +32,16 @@ func GetConfigVersions(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/config-versions/{id} [get]
|
||||
func GetConfigVersion(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid id",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
version, err := service.GetConfigVersionDetail(uint(id))
|
||||
version, err := service.GetConfigVersionDetail(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": version,
|
||||
})
|
||||
respondSuccess(c, version)
|
||||
}
|
||||
|
||||
// GetActiveConfigVersion godoc
|
||||
@@ -74,17 +54,10 @@ func GetConfigVersion(c *gin.Context) {
|
||||
func GetActiveConfigVersion(c *gin.Context) {
|
||||
version, err := service.GetActiveConfigVersion()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "当前没有激活版本",
|
||||
})
|
||||
respondFailure(c, "当前没有激活版本")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": version,
|
||||
})
|
||||
respondSuccess(c, version)
|
||||
}
|
||||
|
||||
// PreviewConfigVersion godoc
|
||||
@@ -97,17 +70,10 @@ func GetActiveConfigVersion(c *gin.Context) {
|
||||
func PreviewConfigVersion(c *gin.Context) {
|
||||
preview, err := service.PreviewConfigVersion()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": preview,
|
||||
})
|
||||
respondSuccess(c, preview)
|
||||
}
|
||||
|
||||
// DiffConfigVersion godoc
|
||||
@@ -120,17 +86,10 @@ func PreviewConfigVersion(c *gin.Context) {
|
||||
func DiffConfigVersion(c *gin.Context) {
|
||||
diff, err := service.DiffConfigVersion()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": diff,
|
||||
})
|
||||
respondSuccess(c, diff)
|
||||
}
|
||||
|
||||
// PublishConfigVersion godoc
|
||||
@@ -145,17 +104,10 @@ func PublishConfigVersion(c *gin.Context) {
|
||||
force := c.Query("force") == "true"
|
||||
result, err := service.PublishConfigVersion(username, force)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": result.Version,
|
||||
})
|
||||
respondSuccess(c, result.Version)
|
||||
}
|
||||
|
||||
// ActivateConfigVersion godoc
|
||||
@@ -168,27 +120,16 @@ func PublishConfigVersion(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/config-versions/{id}/activate [post]
|
||||
func ActivateConfigVersion(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid id",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
version, err := service.ActivateConfigVersion(uint(id))
|
||||
version, err := service.ActivateConfigVersion(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": version,
|
||||
})
|
||||
respondSuccess(c, version)
|
||||
}
|
||||
|
||||
type CleanupConfigVersionRequest struct {
|
||||
@@ -206,26 +147,17 @@ type CleanupConfigVersionRequest struct {
|
||||
// @Router /api/config-versions/cleanup [post]
|
||||
func CleanupConfigVersions(c *gin.Context) {
|
||||
var req CleanupConfigVersionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
})
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
deletedCount, err := service.CleanupConfigVersions(req.KeepCount)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
respondSuccessWithExtras(c, map[string]interface{}{"deleted_count": deletedCount}, gin.H{
|
||||
"message": "清理成功",
|
||||
"data": map[string]interface{}{"deleted_count": deletedCount},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -18,11 +17,7 @@ import (
|
||||
func CleanupDatabaseObservability(c *gin.Context) {
|
||||
var input service.DatabaseCleanupInput
|
||||
if err := decodeOptionalJSONBody(c.Request.Body, &input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "参数错误",
|
||||
"error": err.Error(),
|
||||
})
|
||||
respondBadRequest(c, "")
|
||||
return
|
||||
}
|
||||
result, err := service.CleanupDatabaseObservability(input)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/model"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DnsAccountInput struct {
|
||||
@@ -24,17 +22,10 @@ type DnsAccountInput struct {
|
||||
func GetDnsAccounts(c *gin.Context) {
|
||||
accounts, err := model.ListDnsAccounts()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": accounts,
|
||||
})
|
||||
respondSuccess(c, accounts)
|
||||
}
|
||||
|
||||
// CreateDnsAccount godoc
|
||||
@@ -48,11 +39,7 @@ func GetDnsAccounts(c *gin.Context) {
|
||||
// @Router /api/dns-accounts/ [post]
|
||||
func CreateDnsAccount(c *gin.Context) {
|
||||
var input DnsAccountInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,18 +50,11 @@ func CreateDnsAccount(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := account.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": account,
|
||||
})
|
||||
respondSuccess(c, account)
|
||||
}
|
||||
|
||||
// UpdateDnsAccount godoc
|
||||
@@ -88,30 +68,19 @@ func CreateDnsAccount(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/dns-accounts/{id}/update [post]
|
||||
func UpdateDnsAccount(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input DnsAccountInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
|
||||
account, err := model.GetDnsAccountByID(uint(id))
|
||||
account, err := model.GetDnsAccountByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,18 +89,11 @@ func UpdateDnsAccount(c *gin.Context) {
|
||||
account.Authorization = input.Authorization
|
||||
|
||||
if err := account.Update(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": account,
|
||||
})
|
||||
respondSuccess(c, account)
|
||||
}
|
||||
|
||||
// DeleteDnsAccount godoc
|
||||
@@ -143,21 +105,14 @@ func UpdateDnsAccount(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/dns-accounts/{id}/delete [post]
|
||||
func DeleteDnsAccount(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
account, err := model.GetDnsAccountByID(uint(id))
|
||||
account, err := model.GetDnsAccountByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -165,23 +120,14 @@ func DeleteDnsAccount(c *gin.Context) {
|
||||
var count int64
|
||||
model.DB.Model(&model.TLSCertificate{}).Where("dns_account_id = ?", id).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该 DNS 账号已被证书使用,无法删除",
|
||||
})
|
||||
respondFailure(c, "该 DNS 账号已被证书使用,无法删除")
|
||||
return
|
||||
}
|
||||
|
||||
if err := account.Delete(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
respondSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ type geoIPLookupRequest struct {
|
||||
// @Router /api/option/geoip/lookup [post]
|
||||
func LookupGeoIP(c *gin.Context) {
|
||||
var request geoIPLookupRequest
|
||||
if err := decodeJSONBody(c.Request.Body, &request); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
if !bindJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GitHubOAuthResponse struct {
|
||||
@@ -87,19 +87,13 @@ func GitHubOAuth(c *gin.Context) {
|
||||
}
|
||||
|
||||
if !common.GitHubOAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 GitHub 登录以及注册",
|
||||
})
|
||||
respondFailure(c, "管理员未开启通过 GitHub 登录以及注册")
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
githubUser, err := getGitHubUserInfoByCode(code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
@@ -108,45 +102,16 @@ func GitHubOAuth(c *gin.Context) {
|
||||
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
|
||||
err := user.FillUserByGitHubId()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if common.RegisterEnabled {
|
||||
user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
if githubUser.Name != "" {
|
||||
user.DisplayName = githubUser.Name
|
||||
} else {
|
||||
user.DisplayName = "GitHub User"
|
||||
}
|
||||
user.Email = githubUser.Email
|
||||
user.Role = common.RoleCommonUser
|
||||
user.Status = common.UserStatusEnabled
|
||||
|
||||
if err := user.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员关闭了新用户注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
respondFailure(c, "管理员关闭了新用户注册")
|
||||
return
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "用户已被封禁",
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, "用户已被封禁")
|
||||
return
|
||||
}
|
||||
setupLogin(&user, c)
|
||||
@@ -154,29 +119,20 @@ func GitHubOAuth(c *gin.Context) {
|
||||
|
||||
func GitHubBind(c *gin.Context) {
|
||||
if !common.GitHubOAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 GitHub 登录以及注册",
|
||||
})
|
||||
respondFailure(c, "管理员未开启通过 GitHub 登录以及注册")
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
githubUser, err := getGitHubUserInfoByCode(code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
GitHubId: githubUser.Login,
|
||||
}
|
||||
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该 GitHub 账户已被绑定",
|
||||
})
|
||||
respondFailure(c, "该 GitHub 账户已被绑定")
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
@@ -185,24 +141,14 @@ func GitHubBind(c *gin.Context) {
|
||||
user.Id = id.(int)
|
||||
err = user.FillUserById()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
user.GitHubId = githubUser.Login
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "bind",
|
||||
})
|
||||
return
|
||||
respondSuccessMessage(c, "bind")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetManagedDomains godoc
|
||||
@@ -19,17 +17,10 @@ import (
|
||||
func GetManagedDomains(c *gin.Context) {
|
||||
domains, err := service.ListManagedDomains()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": domains,
|
||||
})
|
||||
respondSuccess(c, domains)
|
||||
}
|
||||
|
||||
// CreateManagedDomain godoc
|
||||
@@ -44,26 +35,15 @@ func GetManagedDomains(c *gin.Context) {
|
||||
// @Router /api/managed-domains/ [post]
|
||||
func CreateManagedDomain(c *gin.Context) {
|
||||
var input service.ManagedDomainInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
domain, err := service.CreateManagedDomain(input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": domain,
|
||||
})
|
||||
respondSuccess(c, domain)
|
||||
}
|
||||
|
||||
// UpdateManagedDomain godoc
|
||||
@@ -78,35 +58,20 @@ func CreateManagedDomain(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/managed-domains/{id}/update [post]
|
||||
func UpdateManagedDomain(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.ManagedDomainInput
|
||||
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
domain, err := service.UpdateManagedDomain(uint(id), input)
|
||||
domain, err := service.UpdateManagedDomain(id, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": domain,
|
||||
})
|
||||
respondSuccess(c, domain)
|
||||
}
|
||||
|
||||
// DeleteManagedDomain godoc
|
||||
@@ -119,25 +84,15 @@ func UpdateManagedDomain(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/managed-domains/{id}/delete [post]
|
||||
func DeleteManagedDomain(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err = service.DeleteManagedDomain(uint(id)); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
if err := service.DeleteManagedDomain(id); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
respondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// MatchManagedDomainCertificate godoc
|
||||
@@ -152,15 +107,8 @@ func MatchManagedDomainCertificate(c *gin.Context) {
|
||||
domain := strings.TrimSpace(c.Query("domain"))
|
||||
result, err := service.MatchManagedDomainCertificate(domain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": result,
|
||||
})
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
"openflare/utils/mail"
|
||||
"openflare/utils/security"
|
||||
"openflare/utils/validation"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetStatus godoc
|
||||
@@ -24,67 +23,43 @@ func GetStatus(c *gin.Context) {
|
||||
if err != nil {
|
||||
authSources = []service.PublicAuthSource{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"version": common.Version,
|
||||
"start_time": common.StartTime,
|
||||
"email_verification": common.EmailVerificationEnabled,
|
||||
"github_oauth": common.GitHubOAuthEnabled,
|
||||
"github_client_id": common.GitHubClientId,
|
||||
"system_name": common.SystemName,
|
||||
"home_page_link": common.HomePageLink,
|
||||
"footer_html": common.Footer,
|
||||
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
|
||||
"wechat_login": common.WeChatAuthEnabled,
|
||||
"server_address": common.ServerAddress,
|
||||
"turnstile_check": common.TurnstileCheckEnabled,
|
||||
"turnstile_site_key": common.TurnstileSiteKey,
|
||||
"register_enabled": common.RegisterEnabled,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"auth_sources": authSources,
|
||||
},
|
||||
respondSuccess(c, gin.H{
|
||||
"version": common.Version,
|
||||
"start_time": common.StartTime,
|
||||
"email_verification": common.EmailVerificationEnabled,
|
||||
"github_oauth": common.GitHubOAuthEnabled,
|
||||
"github_client_id": common.GitHubClientId,
|
||||
"system_name": common.SystemName,
|
||||
"home_page_link": common.HomePageLink,
|
||||
"footer_html": common.Footer,
|
||||
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
|
||||
"wechat_login": common.WeChatAuthEnabled,
|
||||
"server_address": common.ServerAddress,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"auth_sources": authSources,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetNotice(c *gin.Context) {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
defer common.OptionMapRWMutex.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": common.OptionMap["Notice"],
|
||||
})
|
||||
return
|
||||
respondSuccess(c, common.OptionMap["Notice"])
|
||||
}
|
||||
|
||||
func GetAbout(c *gin.Context) {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
defer common.OptionMapRWMutex.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": common.OptionMap["About"],
|
||||
})
|
||||
return
|
||||
respondSuccess(c, common.OptionMap["About"])
|
||||
}
|
||||
|
||||
func SendEmailVerification(c *gin.Context) {
|
||||
email := c.Query("email")
|
||||
if err := validation.Validate.Var(email, "required,email"); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
respondFailure(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
if model.IsEmailAlreadyTaken(email) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "邮箱地址已被占用",
|
||||
})
|
||||
respondFailure(c, "邮箱地址已被占用")
|
||||
return
|
||||
}
|
||||
code := security.GenerateVerificationCode(6)
|
||||
@@ -93,35 +68,29 @@ func SendEmailVerification(c *gin.Context) {
|
||||
content := fmt.Sprintf("<p>您好,你正在进行%s邮箱验证。</p>"+
|
||||
"<p>您的验证码为: <strong>%s</strong></p>"+
|
||||
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, security.VerificationValidMinutes)
|
||||
err := mail.SendEmail(subject, email, content)
|
||||
cfg := mail.SMTPConfig{
|
||||
Server: common.SMTPServer,
|
||||
Port: common.SMTPPort,
|
||||
Account: common.SMTPAccount,
|
||||
Token: common.SMTPToken,
|
||||
SystemName: common.SystemName,
|
||||
}
|
||||
err := mail.SendEmail(cfg, subject, email, content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func SendPasswordResetEmail(c *gin.Context) {
|
||||
email := c.Query("email")
|
||||
if err := validation.Validate.Var(email, "required,email"); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
respondFailure(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
if !model.IsEmailAlreadyTaken(email) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该邮箱地址未注册",
|
||||
})
|
||||
respondFailure(c, "该邮箱地址未注册")
|
||||
return
|
||||
}
|
||||
code := security.GenerateVerificationCode(0)
|
||||
@@ -131,19 +100,19 @@ func SendPasswordResetEmail(c *gin.Context) {
|
||||
content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
|
||||
"<p>点击<a href='%s'>此处</a>进行密码重置。</p>"+
|
||||
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, security.VerificationValidMinutes)
|
||||
err := mail.SendEmail(subject, email, content)
|
||||
cfg := mail.SMTPConfig{
|
||||
Server: common.SMTPServer,
|
||||
Port: common.SMTPPort,
|
||||
Account: common.SMTPAccount,
|
||||
Token: common.SMTPToken,
|
||||
SystemName: common.SystemName,
|
||||
}
|
||||
err := mail.SendEmail(cfg, subject, email, content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
type PasswordResetRequest struct {
|
||||
@@ -153,35 +122,23 @@ type PasswordResetRequest struct {
|
||||
|
||||
func ResetPassword(c *gin.Context) {
|
||||
var req PasswordResetRequest
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&req)
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if req.Email == "" || req.Token == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
respondFailure(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
if !security.VerifyCodeWithKey(req.Email, req.Token, security.PasswordResetPurpose) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "重置链接非法或已过期",
|
||||
})
|
||||
respondFailure(c, "重置链接非法或已过期")
|
||||
return
|
||||
}
|
||||
password := security.GenerateVerificationCode(12)
|
||||
err = model.ResetUserPasswordByEmail(req.Email, password)
|
||||
err := model.ResetUserPasswordByEmail(req.Email, password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
security.DeleteKey(req.Email, security.PasswordResetPurpose)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": password,
|
||||
})
|
||||
return
|
||||
respondSuccess(c, password)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -29,8 +28,7 @@ type nodeObservabilityQuery struct {
|
||||
// @Router /api/nodes/ [post]
|
||||
func CreateNode(c *gin.Context) {
|
||||
var input service.NodeInput
|
||||
if err := decodeJSONBody(c.Request.Body, &input); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,19 +84,17 @@ func RotateNodeBootstrapToken(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/nodes/{id}/update [post]
|
||||
func UpdateNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input service.NodeInput
|
||||
if err = decodeJSONBody(c.Request.Body, &input); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
|
||||
node, err := service.UpdateNode(uint(id), input)
|
||||
node, err := service.UpdateNode(id, input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
@@ -116,13 +112,12 @@ func UpdateNode(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/nodes/{id}/delete [post]
|
||||
func DeleteNode(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err = service.DeleteNode(uint(id)); err != nil {
|
||||
if err := service.DeleteNode(id); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -139,21 +134,20 @@ func DeleteNode(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/nodes/{id}/agent-update [post]
|
||||
func RequestNodeAgentUpdate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var request nodeAgentUpdateRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err = decodeOptionalJSONBody(c.Request.Body, &request); err != nil {
|
||||
if err := decodeOptionalJSONBody(c.Request.Body, &request); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
node, err := service.RequestNodeAgentUpdate(uint(id), service.NodeAgentUpdateInput{
|
||||
node, err := service.RequestNodeAgentUpdate(id, service.NodeAgentUpdateInput{
|
||||
Channel: request.Channel,
|
||||
TagName: request.TagName,
|
||||
})
|
||||
@@ -174,13 +168,12 @@ func RequestNodeAgentUpdate(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/nodes/{id}/openresty-restart [post]
|
||||
func RequestNodeOpenrestyRestart(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
node, err := service.RequestNodeOpenrestyRestart(uint(id))
|
||||
node, err := service.RequestNodeOpenrestyRestart(id)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
@@ -198,13 +191,12 @@ func RequestNodeOpenrestyRestart(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/nodes/{id}/force-sync [post]
|
||||
func RequestNodeForceSync(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
node, err := service.RequestNodeForceSync(uint(id))
|
||||
node, err := service.RequestNodeForceSync(id)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
@@ -223,13 +215,12 @@ func RequestNodeForceSync(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/nodes/{id}/agent-release [get]
|
||||
func GetNodeAgentRelease(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
release, err := service.GetNodeAgentRelease(c.Request.Context(), uint(id), c.Query("channel"))
|
||||
release, err := service.GetNodeAgentRelease(c.Request.Context(), id, c.Query("channel"))
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
@@ -249,19 +240,18 @@ func GetNodeAgentRelease(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/nodes/{id}/observability [get]
|
||||
func GetNodeObservability(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var query nodeObservabilityQuery
|
||||
if err = c.ShouldBindQuery(&query); err != nil {
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
view, err := service.GetNodeObservability(uint(id), service.NodeObservabilityQuery{
|
||||
view, err := service.GetNodeObservability(id, service.NodeObservabilityQuery{
|
||||
Hours: query.Hours,
|
||||
Limit: query.Limit,
|
||||
})
|
||||
@@ -282,13 +272,12 @@ func GetNodeObservability(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/nodes/{id}/observability/cleanup [post]
|
||||
func CleanupNodeHealthEvents(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := service.CleanupNodeHealthEvents(uint(id))
|
||||
result, err := service.CleanupNodeHealthEvents(id)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
@@ -13,6 +10,8 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,13 +29,13 @@ func validateRateLimitOption(key string, value string) error {
|
||||
maxDurationSeconds := int(common.RateLimitKeyExpirationDuration.Seconds())
|
||||
|
||||
switch key {
|
||||
case "GlobalApiRateLimitNum", "GlobalWebRateLimitNum", "UploadRateLimitNum", "DownloadRateLimitNum", "CriticalRateLimitNum":
|
||||
case "GlobalApiRateLimitNum", "GlobalWebRateLimitNum", "CriticalRateLimitNum":
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil || intValue <= 0 {
|
||||
return fmt.Errorf("%s 必须为大于 0 的整数", key)
|
||||
}
|
||||
return nil
|
||||
case "GlobalApiRateLimitDuration", "GlobalWebRateLimitDuration", "UploadRateLimitDuration", "DownloadRateLimitDuration", "CriticalRateLimitDuration":
|
||||
case "GlobalApiRateLimitDuration", "GlobalWebRateLimitDuration", "CriticalRateLimitDuration":
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil || intValue <= 0 {
|
||||
return fmt.Errorf("%s 必须为大于 0 的整数秒", key)
|
||||
@@ -230,16 +229,13 @@ func validateOptionWithState(option model.Option, state map[string]string) error
|
||||
switch option.Key {
|
||||
case "GitHubOAuthEnabled":
|
||||
if option.Value == "true" && strings.TrimSpace(state["GitHubClientId"]) == "" {
|
||||
return fmt.Errorf("鏃犳硶鍚敤 GitHub OAuth锛岃鍏堝~鍏?GitHub Client ID 浠ュ強 GitHub Client Secret锛?")
|
||||
return fmt.Errorf("无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!")
|
||||
}
|
||||
case "WeChatAuthEnabled":
|
||||
if option.Value == "true" && strings.TrimSpace(state["WeChatServerAddress"]) == "" {
|
||||
return fmt.Errorf("鏃犳硶鍚敤寰俊鐧诲綍锛岃鍏堝~鍏ュ井淇$櫥褰曠浉鍏抽厤缃俊鎭紒")
|
||||
}
|
||||
case "TurnstileCheckEnabled":
|
||||
if option.Value == "true" && strings.TrimSpace(state["TurnstileSiteKey"]) == "" {
|
||||
return fmt.Errorf("鏃犳硶鍚敤 Turnstile 鏍¢獙锛岃鍏堝~鍏?Turnstile 鏍¢獙鐩稿叧閰嶇疆淇℃伅锛?")
|
||||
return fmt.Errorf("无法启用微信登录,请先填入微信登录相关配置信息!")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
|
||||
@@ -262,13 +258,13 @@ func validateOptionWithState(option model.Option, state map[string]string) error
|
||||
|
||||
func updateOptions(options []model.Option) error {
|
||||
if len(options) == 0 {
|
||||
return fmt.Errorf("鏃犳晥鐨勫弬鏁?")
|
||||
return fmt.Errorf("无效的参数")
|
||||
}
|
||||
|
||||
state := buildOptionValidationState(options)
|
||||
for _, option := range options {
|
||||
if strings.TrimSpace(option.Key) == "" {
|
||||
return fmt.Errorf("鏃犳晥鐨勫弬鏁?")
|
||||
return fmt.Errorf("无效的参数")
|
||||
}
|
||||
if err := validateOptionWithState(option, state); err != nil {
|
||||
return err
|
||||
@@ -297,12 +293,7 @@ func GetOptions(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": options,
|
||||
})
|
||||
return
|
||||
respondSuccess(c, options)
|
||||
}
|
||||
|
||||
// UpdateOption godoc
|
||||
@@ -316,88 +307,47 @@ func GetOptions(c *gin.Context) {
|
||||
// @Router /api/option/update [post]
|
||||
func UpdateOption(c *gin.Context) {
|
||||
var option model.Option
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&option)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &option) {
|
||||
return
|
||||
}
|
||||
switch option.Key {
|
||||
case "GitHubOAuthEnabled":
|
||||
if option.Value == "true" && common.GitHubClientId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!",
|
||||
})
|
||||
respondFailure(c, "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!")
|
||||
return
|
||||
}
|
||||
case "WeChatAuthEnabled":
|
||||
if option.Value == "true" && common.WeChatServerAddress == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用微信登录,请先填入微信登录相关配置信息!",
|
||||
})
|
||||
return
|
||||
}
|
||||
case "TurnstileCheckEnabled":
|
||||
if option.Value == "true" && common.TurnstileSiteKey == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!",
|
||||
})
|
||||
respondFailure(c, "无法启用微信登录,请先填入微信登录相关配置信息!")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = validateRateLimitOption(option.Key, option.Value); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err = validateOpenRestyOption(option.Key, option.Value); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err = validateGeoIPOption(option.Key, option.Value); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err = validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err = validateAgentOption(option.Key, option.Value); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
if err := validateAgentOption(option.Key, option.Value); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
err = model.UpdateOption(option.Key, option.Value)
|
||||
err := model.UpdateOption(option.Key, option.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
// UpdateOptionsBatch godoc
|
||||
@@ -411,24 +361,18 @@ func UpdateOption(c *gin.Context) {
|
||||
// @Router /api/option/update-batch [post]
|
||||
func UpdateOptionsBatch(c *gin.Context) {
|
||||
var payload optionBatchPayload
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil || len(payload.Options) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "鏃犳晥鐨勫弬鏁?",
|
||||
})
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
if len(payload.Options) == 0 {
|
||||
respondBadRequest(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateOptions(payload.Options); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -12,117 +9,63 @@ import (
|
||||
func GetOrigins(c *gin.Context) {
|
||||
origins, err := service.ListOrigins()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": origins,
|
||||
})
|
||||
respondSuccess(c, origins)
|
||||
}
|
||||
|
||||
func GetOrigin(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
origin, err := service.GetOriginDetail(uint(id))
|
||||
origin, err := service.GetOriginDetail(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": origin,
|
||||
})
|
||||
respondSuccess(c, origin)
|
||||
}
|
||||
|
||||
func CreateOrigin(c *gin.Context) {
|
||||
var input service.OriginInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
origin, err := service.CreateOrigin(input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": origin,
|
||||
})
|
||||
respondSuccess(c, origin)
|
||||
}
|
||||
|
||||
func UpdateOrigin(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.OriginInput
|
||||
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
origin, err := service.UpdateOrigin(uint(id), input)
|
||||
origin, err := service.UpdateOrigin(id, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": origin,
|
||||
})
|
||||
respondSuccess(c, origin)
|
||||
}
|
||||
|
||||
func DeleteOrigin(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err = service.DeleteOrigin(uint(id)); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
if err := service.DeleteOrigin(id); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
respondSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -19,17 +16,10 @@ import (
|
||||
func GetProxyRoutes(c *gin.Context) {
|
||||
routes, err := service.ListProxyRoutes()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": routes,
|
||||
})
|
||||
respondSuccess(c, routes)
|
||||
}
|
||||
|
||||
// GetProxyRoute godoc
|
||||
@@ -42,27 +32,16 @@ func GetProxyRoutes(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/proxy-routes/{id} [get]
|
||||
func GetProxyRoute(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid id",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
route, err := service.GetProxyRoute(uint(id))
|
||||
route, err := service.GetProxyRoute(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": route,
|
||||
})
|
||||
respondSuccess(c, route)
|
||||
}
|
||||
|
||||
// CreateProxyRoute godoc
|
||||
@@ -77,26 +56,15 @@ func GetProxyRoute(c *gin.Context) {
|
||||
// @Router /api/proxy-routes/ [post]
|
||||
func CreateProxyRoute(c *gin.Context) {
|
||||
var input service.ProxyRouteInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid payload",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
route, err := service.CreateProxyRoute(input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": route,
|
||||
})
|
||||
respondSuccess(c, route)
|
||||
}
|
||||
|
||||
// UpdateProxyRoute godoc
|
||||
@@ -111,35 +79,20 @@ func CreateProxyRoute(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/proxy-routes/{id}/update [post]
|
||||
func UpdateProxyRoute(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid id",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.ProxyRouteInput
|
||||
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid payload",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
route, err := service.UpdateProxyRoute(uint(id), input)
|
||||
route, err := service.UpdateProxyRoute(id, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": route,
|
||||
})
|
||||
respondSuccess(c, route)
|
||||
}
|
||||
|
||||
// DeleteProxyRoute godoc
|
||||
@@ -152,23 +105,13 @@ func UpdateProxyRoute(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/proxy-routes/{id}/delete [post]
|
||||
func DeleteProxyRoute(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid id",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err = service.DeleteProxyRoute(uint(id)); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
if err := service.DeleteProxyRoute(id); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
respondSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const invalidParamsMessage = "鏃犳晥鐨勫弬鏁?"
|
||||
const invalidParamsMessage = "参数错误"
|
||||
|
||||
func respondSuccess(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -72,3 +73,20 @@ func decodeOptionalJSONBody(body io.Reader, target any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseIDParam(c *gin.Context) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
respondBadRequest(c, "")
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
func bindJSON(c *gin.Context, target any) bool {
|
||||
if err := decodeJSONBody(c.Request.Body, target); err != nil {
|
||||
respondBadRequest(c, "")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetTLSCertificates godoc
|
||||
@@ -18,17 +16,10 @@ import (
|
||||
func GetTLSCertificates(c *gin.Context) {
|
||||
certificates, err := service.ListTLSCertificates()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificates,
|
||||
})
|
||||
respondSuccess(c, certificates)
|
||||
}
|
||||
|
||||
// GetTLSCertificate godoc
|
||||
@@ -41,28 +32,17 @@ func GetTLSCertificates(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/{id} [get]
|
||||
func GetTLSCertificate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid request",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
certificate, err := service.GetTLSCertificate(uint(id))
|
||||
certificate, err := service.GetTLSCertificate(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
respondSuccess(c, certificate)
|
||||
}
|
||||
|
||||
// GetTLSCertificateContent godoc
|
||||
@@ -75,28 +55,17 @@ func GetTLSCertificate(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/{id}/content [get]
|
||||
func GetTLSCertificateContent(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid request",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
content, err := service.GetTLSCertificateContent(uint(id))
|
||||
content, err := service.GetTLSCertificateContent(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": content,
|
||||
})
|
||||
respondSuccess(c, content)
|
||||
}
|
||||
|
||||
// CreateTLSCertificate godoc
|
||||
@@ -111,26 +80,15 @@ func GetTLSCertificateContent(c *gin.Context) {
|
||||
// @Router /api/tls-certificates/ [post]
|
||||
func CreateTLSCertificate(c *gin.Context) {
|
||||
var input service.TLSCertificateInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
certificate, err := service.CreateTLSCertificate(input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
respondSuccess(c, certificate)
|
||||
}
|
||||
|
||||
// UpdateTLSCertificate godoc
|
||||
@@ -145,37 +103,22 @@ func CreateTLSCertificate(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/{id}/update [post]
|
||||
func UpdateTLSCertificate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid request",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input service.TLSCertificateInput
|
||||
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid request",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
|
||||
certificate, err := service.UpdateTLSCertificate(uint(id), input)
|
||||
certificate, err := service.UpdateTLSCertificate(id, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
respondSuccess(c, certificate)
|
||||
}
|
||||
|
||||
// ImportTLSCertificateFile godoc
|
||||
@@ -196,33 +139,20 @@ func ImportTLSCertificateFile(c *gin.Context) {
|
||||
remark := c.PostForm("remark")
|
||||
certFile, err := c.FormFile("cert_file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "缺少证书文件",
|
||||
})
|
||||
respondBadRequest(c, "缺少证书文件")
|
||||
return
|
||||
}
|
||||
keyFile, err := c.FormFile("key_file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "缺少私钥文件",
|
||||
})
|
||||
respondBadRequest(c, "缺少私钥文件")
|
||||
return
|
||||
}
|
||||
certificate, err := service.CreateTLSCertificateFromFiles(name, certFile, keyFile, remark)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
respondSuccess(c, certificate)
|
||||
}
|
||||
|
||||
// DeleteTLSCertificate godoc
|
||||
@@ -235,25 +165,15 @@ func ImportTLSCertificateFile(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/{id}/delete [post]
|
||||
func DeleteTLSCertificate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err = service.DeleteTLSCertificate(uint(id)); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
if err := service.DeleteTLSCertificate(id); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
respondSuccess(c, nil)
|
||||
}
|
||||
|
||||
// ApplyTLSCertificate godoc
|
||||
@@ -268,26 +188,15 @@ func DeleteTLSCertificate(c *gin.Context) {
|
||||
// @Router /api/tls-certificates/apply [post]
|
||||
func ApplyTLSCertificate(c *gin.Context) {
|
||||
var input service.TLSApplyInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
certificate, err := service.ApplyTLSCertificate(input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
respondSuccess(c, certificate)
|
||||
}
|
||||
|
||||
// UpdateAcmeCertificate godoc
|
||||
@@ -302,36 +211,21 @@ func ApplyTLSCertificate(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/{id}/update-acme [post]
|
||||
func UpdateAcmeCertificate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid request",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input service.TLSApplyInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
certificate, err := service.UpdateAcmeCertificate(uint(id), input)
|
||||
certificate, err := service.UpdateAcmeCertificate(id, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
respondSuccess(c, certificate)
|
||||
}
|
||||
|
||||
// ConvertTLSCertificateToAcme godoc
|
||||
@@ -346,36 +240,21 @@ func UpdateAcmeCertificate(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/{id}/convert-acme [post]
|
||||
func ConvertTLSCertificateToAcme(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "invalid request",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var input service.TLSApplyInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
certificate, err := service.ConvertTLSCertificateToAcme(uint(id), input)
|
||||
certificate, err := service.ConvertTLSCertificateToAcme(id, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
respondSuccess(c, certificate)
|
||||
}
|
||||
|
||||
// RenewTLSCertificate godoc
|
||||
@@ -388,25 +267,14 @@ func ConvertTLSCertificateToAcme(c *gin.Context) {
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/tls-certificates/{id}/renew [post]
|
||||
func RenewTLSCertificate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
certificate, err := service.RenewTLSCertificate(uint(id))
|
||||
certificate, err := service.RenewTLSCertificate(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
respondSuccess(c, certificate)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -24,23 +21,16 @@ type serverUpgradeRequest struct {
|
||||
// @Summary Get latest GitHub release
|
||||
// @Tags Update
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/update/latest-release [get]
|
||||
func GetLatestRelease(c *gin.Context) {
|
||||
release, err := service.GetLatestServerRelease(c.Request.Context(), c.Query("channel"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": release,
|
||||
})
|
||||
respondSuccess(c, release)
|
||||
}
|
||||
|
||||
// UpgradeServer godoc
|
||||
@@ -52,27 +42,19 @@ func GetLatestRelease(c *gin.Context) {
|
||||
func UpgradeServer(c *gin.Context) {
|
||||
var request serverUpgradeRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if err := decodeOptionalJSONBody(c.Request.Body, &request); err != nil {
|
||||
respondBadRequest(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
}
|
||||
release, err := service.ScheduleServerUpgrade(request.Channel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
respondSuccessWithExtras(c, release, gin.H{
|
||||
"message": "服务升级任务已启动,下载完成后将自动重启。",
|
||||
"data": release,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,19 +104,13 @@ func StreamServerUpgradeLogs(c *gin.Context) {
|
||||
func UploadManualServerBinary(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("binary")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请先选择要上传的服务端二进制文件。",
|
||||
})
|
||||
respondFailure(c, "请先选择要上传的服务端二进制文件。")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "读取上传文件失败。",
|
||||
})
|
||||
respondFailure(c, "读取上传文件失败。")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
@@ -143,10 +119,7 @@ func UploadManualServerBinary(c *gin.Context) {
|
||||
|
||||
info, err := service.UploadManualServerBinary(c.Request.Context(), fileHeader.Filename, file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -155,10 +128,8 @@ func UploadManualServerBinary(c *gin.Context) {
|
||||
message = "已完成上传并检查升级包版本。"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
respondSuccessWithExtras(c, info, gin.H{
|
||||
"message": message,
|
||||
"data": info,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,26 +142,17 @@ func UploadManualServerBinary(c *gin.Context) {
|
||||
// @Router /api/update/manual-upgrade [post]
|
||||
func ConfirmManualServerUpgrade(c *gin.Context) {
|
||||
var request confirmManualUpgradeRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "升级确认参数无效。",
|
||||
})
|
||||
if !bindJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
info, err := service.ConfirmManualServerUpgrade(request.UploadToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
respondSuccessWithExtras(c, info, gin.H{
|
||||
"message": "服务升级任务已启动,确认无误后将自动重启。",
|
||||
"data": info,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/utils/security"
|
||||
@@ -22,40 +20,26 @@ type LoginRequest struct {
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
if !common.PasswordLoginEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员关闭了密码登录",
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, "管理员关闭了密码登录")
|
||||
return
|
||||
}
|
||||
var loginRequest LoginRequest
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&loginRequest)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无效的参数",
|
||||
"success": false,
|
||||
})
|
||||
if !bindJSON(c, &loginRequest) {
|
||||
return
|
||||
}
|
||||
username := loginRequest.Username
|
||||
password := loginRequest.Password
|
||||
if username == "" || password == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无效的参数",
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
err = user.ValidateAndFill()
|
||||
err := user.ValidateAndFill()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": err.Error(),
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
setupLogin(&user, c)
|
||||
@@ -85,17 +69,10 @@ func setLoginSession(user *model.User, c *gin.Context) (*model.User, error) {
|
||||
func setupLogin(user *model.User, c *gin.Context) {
|
||||
cleanUser, err := setLoginSession(user, c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无法保存会话信息,请重试",
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, "无法保存会话信息,请重试")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "",
|
||||
"success": true,
|
||||
"data": *cleanUser,
|
||||
})
|
||||
respondSuccess(c, *cleanUser)
|
||||
}
|
||||
|
||||
func Logout(c *gin.Context) {
|
||||
@@ -103,24 +80,14 @@ func Logout(c *gin.Context) {
|
||||
session.Clear()
|
||||
err := session.Save()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": err.Error(),
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "",
|
||||
"success": true,
|
||||
})
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "非法请求",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
respondFailure(c, "非法请求")
|
||||
}
|
||||
|
||||
func GetAllUsers(c *gin.Context) {
|
||||
@@ -130,167 +97,101 @@ func GetAllUsers(c *gin.Context) {
|
||||
}
|
||||
users, err := model.GetAllUsers(p*common.ItemsPerPage, common.ItemsPerPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": users,
|
||||
})
|
||||
return
|
||||
respondSuccess(c, users)
|
||||
}
|
||||
|
||||
func SearchUsers(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
users, err := model.SearchUsers(keyword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": users,
|
||||
})
|
||||
return
|
||||
respondSuccess(c, users)
|
||||
}
|
||||
|
||||
func GetUser(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, err := model.GetUserById(id, false)
|
||||
user, err := model.GetUserById(int(id), false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权获取同级或更高等级用户的信息",
|
||||
})
|
||||
respondFailure(c, "无权获取同级或更高等级用户的信息")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": user,
|
||||
})
|
||||
return
|
||||
respondSuccess(c, user)
|
||||
}
|
||||
|
||||
func GenerateToken(c *gin.Context) {
|
||||
id := c.GetInt("id")
|
||||
user, err := model.GetUserById(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
user.Token = uuid.New().String()
|
||||
user.Token = strings.Replace(user.Token, "-", "", -1)
|
||||
|
||||
if model.DB.Where("token = ?", user.Token).First(user).RowsAffected != 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请重试,系统生成的 UUID 竟然重复了!",
|
||||
})
|
||||
respondFailure(c, "请重试,系统生成的 UUID 竟然重复了!")
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.Update(false); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": user.Token,
|
||||
})
|
||||
return
|
||||
respondSuccess(c, user.Token)
|
||||
}
|
||||
|
||||
func GetSelf(c *gin.Context) {
|
||||
id := c.GetInt("id")
|
||||
user, err := model.GetUserById(id, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": user,
|
||||
})
|
||||
return
|
||||
respondSuccess(c, user)
|
||||
}
|
||||
|
||||
func UpdateUser(c *gin.Context) {
|
||||
var updatedUser model.User
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
|
||||
if err != nil || updatedUser.Id == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &updatedUser) {
|
||||
return
|
||||
}
|
||||
if updatedUser.Id == 0 {
|
||||
respondFailure(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
if updatedUser.Password == "" {
|
||||
updatedUser.Password = "$I_LOVE_U" // make Validator happy :)
|
||||
}
|
||||
if err := validation.Validate.Struct(&updatedUser); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "输入不合法 " + err.Error(),
|
||||
})
|
||||
respondFailure(c, "输入不合法 "+err.Error())
|
||||
return
|
||||
}
|
||||
originUser, err := model.GetUserById(updatedUser.Id, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= originUser.Role {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权更新同权限等级或更高权限等级的用户信息",
|
||||
})
|
||||
respondFailure(c, "无权更新同权限等级或更高权限等级的用户信息")
|
||||
return
|
||||
}
|
||||
if myRole <= updatedUser.Role {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权将其他用户权限等级提升到大于等于自己的权限等级",
|
||||
})
|
||||
respondFailure(c, "无权将其他用户权限等级提升到大于等于自己的权限等级")
|
||||
return
|
||||
}
|
||||
if updatedUser.Password == "$I_LOVE_U" {
|
||||
@@ -298,37 +199,22 @@ func UpdateUser(c *gin.Context) {
|
||||
}
|
||||
updatePassword := updatedUser.Password != ""
|
||||
if err := updatedUser.Update(updatePassword); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func UpdateSelf(c *gin.Context) {
|
||||
var user model.User
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &user) {
|
||||
return
|
||||
}
|
||||
if user.Password == "" {
|
||||
user.Password = "$I_LOVE_U" // make Validator happy :)
|
||||
}
|
||||
if err := validation.Validate.Struct(&user); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "输入不合法 " + err.Error(),
|
||||
})
|
||||
respondFailure(c, "输入不合法 "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -344,80 +230,53 @@ func UpdateSelf(c *gin.Context) {
|
||||
}
|
||||
updatePassword := user.Password != ""
|
||||
if err := cleanUser.Update(updatePassword); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func DeleteUser(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
originUser, err := model.GetUserById(id, false)
|
||||
originUser, err := model.GetUserById(int(id), false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= originUser.Role {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权删除同权限等级或更高权限等级的用户",
|
||||
})
|
||||
respondFailure(c, "无权删除同权限等级或更高权限等级的用户")
|
||||
return
|
||||
}
|
||||
err = model.DeleteUserById(id)
|
||||
err = model.DeleteUserById(int(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func DeleteSelf(c *gin.Context) {
|
||||
id := c.GetInt("id")
|
||||
err := model.DeleteUserById(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func CreateUser(c *gin.Context) {
|
||||
var user model.User
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&user)
|
||||
if err != nil || user.Username == "" || user.Password == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &user) {
|
||||
return
|
||||
}
|
||||
if user.Username == "" || user.Password == "" {
|
||||
respondFailure(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
if user.DisplayName == "" {
|
||||
@@ -425,10 +284,7 @@ func CreateUser(c *gin.Context) {
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if user.Role >= myRole {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法创建权限大于等于自己的用户",
|
||||
})
|
||||
respondFailure(c, "无法创建权限大于等于自己的用户")
|
||||
return
|
||||
}
|
||||
// Even for admin users, we cannot fully trust them!
|
||||
@@ -438,18 +294,11 @@ func CreateUser(c *gin.Context) {
|
||||
DisplayName: user.DisplayName,
|
||||
}
|
||||
if err := cleanUser.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
type ManageRequest struct {
|
||||
@@ -460,13 +309,7 @@ type ManageRequest struct {
|
||||
// ManageUser Only admin user can do this
|
||||
func ManageUser(c *gin.Context) {
|
||||
var req ManageRequest
|
||||
err := json.NewDecoder(c.Request.Body).Decode(&req)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
@@ -475,108 +318,70 @@ func ManageUser(c *gin.Context) {
|
||||
// Fill attributes
|
||||
model.DB.Where(&user).First(&user)
|
||||
if user.Id == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户不存在",
|
||||
})
|
||||
respondFailure(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权更新同权限等级或更高权限等级的用户信息",
|
||||
})
|
||||
respondFailure(c, "无权更新同权限等级或更高权限等级的用户信息")
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "disable":
|
||||
user.Status = common.UserStatusDisabled
|
||||
if user.Role == common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法禁用超级管理员用户",
|
||||
})
|
||||
respondFailure(c, "无法禁用超级管理员用户")
|
||||
return
|
||||
}
|
||||
case "enable":
|
||||
user.Status = common.UserStatusEnabled
|
||||
case "delete":
|
||||
if user.Role == common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法删除超级管理员用户",
|
||||
})
|
||||
respondFailure(c, "无法删除超级管理员用户")
|
||||
return
|
||||
}
|
||||
if err := user.Delete(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
case "promote":
|
||||
if myRole != common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "普通管理员用户无法提升其他用户为管理员",
|
||||
})
|
||||
respondFailure(c, "普通管理员用户无法提升其他用户为管理员")
|
||||
return
|
||||
}
|
||||
if user.Role >= common.RoleAdminUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该用户已经是管理员",
|
||||
})
|
||||
respondFailure(c, "该用户已经是管理员")
|
||||
return
|
||||
}
|
||||
user.Role = common.RoleAdminUser
|
||||
case "demote":
|
||||
if user.Role == common.RoleRootUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无法降级超级管理员用户",
|
||||
})
|
||||
respondFailure(c, "无法降级超级管理员用户")
|
||||
return
|
||||
}
|
||||
if user.Role == common.RoleCommonUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该用户已经是普通用户",
|
||||
})
|
||||
respondFailure(c, "该用户已经是普通用户")
|
||||
return
|
||||
}
|
||||
user.Role = common.RoleCommonUser
|
||||
}
|
||||
|
||||
if err := user.Update(false); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
clearUser := model.User{
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": clearUser,
|
||||
})
|
||||
return
|
||||
respondSuccess(c, clearUser)
|
||||
}
|
||||
|
||||
func EmailBind(c *gin.Context) {
|
||||
email := c.Query("email")
|
||||
code := c.Query("code")
|
||||
if !security.VerifyCodeWithKey(email, code, security.EmailVerificationPurpose) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "验证码错误或已过期",
|
||||
})
|
||||
respondFailure(c, "验证码错误或已过期")
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
@@ -585,25 +390,15 @@ func EmailBind(c *gin.Context) {
|
||||
}
|
||||
err := user.FillUserById()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
user.Email = email
|
||||
// no need to check if this email already taken, because we have used verification code to check it
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
@@ -16,10 +14,10 @@ type wafIDsRequest struct {
|
||||
func ListWAFRuleGroups(c *gin.Context) {
|
||||
groups, err := service.ListWAFRuleGroups()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": groups})
|
||||
respondSuccess(c, groups)
|
||||
}
|
||||
|
||||
func GetWAFRuleGroup(c *gin.Context) {
|
||||
@@ -29,24 +27,23 @@ func GetWAFRuleGroup(c *gin.Context) {
|
||||
}
|
||||
group, err := service.GetWAFRuleGroup(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": group})
|
||||
respondSuccess(c, group)
|
||||
}
|
||||
|
||||
func CreateWAFRuleGroup(c *gin.Context) {
|
||||
var input service.WAFRuleGroupInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid payload"})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
group, err := service.CreateWAFRuleGroup(input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": group})
|
||||
respondSuccess(c, group)
|
||||
}
|
||||
|
||||
func UpdateWAFRuleGroup(c *gin.Context) {
|
||||
@@ -55,16 +52,15 @@ func UpdateWAFRuleGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var input service.WAFRuleGroupInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid payload"})
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
group, err := service.UpdateWAFRuleGroup(id, input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": group})
|
||||
respondSuccess(c, group)
|
||||
}
|
||||
|
||||
func DeleteWAFRuleGroup(c *gin.Context) {
|
||||
@@ -73,10 +69,10 @@ func DeleteWAFRuleGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := service.DeleteWAFRuleGroup(id); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func ReplaceWAFRuleGroupSites(c *gin.Context) {
|
||||
@@ -85,16 +81,15 @@ func ReplaceWAFRuleGroupSites(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var request wafIDsRequest
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid payload"})
|
||||
if !bindJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
group, err := service.ReplaceWAFRuleGroupSites(id, request.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": group})
|
||||
respondSuccess(c, group)
|
||||
}
|
||||
|
||||
func GetWAFSiteRuleGroups(c *gin.Context) {
|
||||
@@ -104,10 +99,10 @@ func GetWAFSiteRuleGroups(c *gin.Context) {
|
||||
}
|
||||
view, err := service.GetWAFSiteRuleGroups(routeID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": view})
|
||||
respondSuccess(c, view)
|
||||
}
|
||||
|
||||
func ReplaceWAFSiteRuleGroups(c *gin.Context) {
|
||||
@@ -116,22 +111,21 @@ func ReplaceWAFSiteRuleGroups(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var request wafIDsRequest
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid payload"})
|
||||
if !bindJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
view, err := service.ReplaceWAFSiteRuleGroups(routeID, request.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": view})
|
||||
respondSuccess(c, view)
|
||||
}
|
||||
|
||||
func parseUintPathParam(c *gin.Context, name string) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param(name), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid id"})
|
||||
respondBadRequest(c, "invalid id")
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type wechatLoginResponse struct {
|
||||
@@ -34,7 +36,12 @@ func getWeChatIdByCode(code string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer httpResponse.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("Failed to close response body", "error", err)
|
||||
}
|
||||
}(httpResponse.Body)
|
||||
var res wechatLoginResponse
|
||||
err = json.NewDecoder(httpResponse.Body).Decode(&res)
|
||||
if err != nil {
|
||||
@@ -51,19 +58,13 @@ func getWeChatIdByCode(code string) (string, error) {
|
||||
|
||||
func WeChatAuth(c *gin.Context) {
|
||||
if !common.WeChatAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员未开启通过微信登录以及注册",
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, "管理员未开启通过微信登录以及注册")
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
wechatId, err := getWeChatIdByCode(code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": err.Error(),
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
@@ -72,40 +73,16 @@ func WeChatAuth(c *gin.Context) {
|
||||
if model.IsWeChatIdAlreadyTaken(wechatId) {
|
||||
err := user.FillUserByWeChatId()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if common.RegisterEnabled {
|
||||
user.Username = "wechat_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
user.DisplayName = "WeChat User"
|
||||
user.Role = common.RoleCommonUser
|
||||
user.Status = common.UserStatusEnabled
|
||||
|
||||
if err := user.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员关闭了新用户注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
respondFailure(c, "管理员关闭了新用户注册")
|
||||
return
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "用户已被封禁",
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, "用户已被封禁")
|
||||
return
|
||||
}
|
||||
setupLogin(&user, c)
|
||||
@@ -113,26 +90,17 @@ func WeChatAuth(c *gin.Context) {
|
||||
|
||||
func WeChatBind(c *gin.Context) {
|
||||
if !common.WeChatAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "管理员未开启通过微信登录以及注册",
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, "管理员未开启通过微信登录以及注册")
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
wechatId, err := getWeChatIdByCode(code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": err.Error(),
|
||||
"success": false,
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
if model.IsWeChatIdAlreadyTaken(wechatId) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该微信账号已被绑定",
|
||||
})
|
||||
respondFailure(c, "该微信账号已被绑定")
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
@@ -141,24 +109,15 @@ func WeChatBind(c *gin.Context) {
|
||||
}
|
||||
err = user.FillUserById()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
user.WeChatId = wechatId
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
respondSuccessMessage(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2994,6 +2994,9 @@ const docTemplate = `{
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"ip_manual_override": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -2991,6 +2991,9 @@
|
||||
"ip": {
|
||||
"type": "string"
|
||||
},
|
||||
"ip_manual_override": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -3193,4 +3196,4 @@
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,8 @@ definitions:
|
||||
type: string
|
||||
ip:
|
||||
type: string
|
||||
ip_manual_override:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/robfig/cron/v3"
|
||||
"log/slog"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
var cronRunner *cron.Cron
|
||||
|
||||
@@ -2,12 +2,13 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/utils/ratelimit"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var timeFormat = "2006-01-02T15:04:05.000Z"
|
||||
@@ -52,11 +53,11 @@ func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark st
|
||||
c.Status(http.StatusTooManyRequests)
|
||||
c.Abort()
|
||||
return
|
||||
} else {
|
||||
rdb.LPush(ctx, key, time.Now().Format(timeFormat))
|
||||
rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))
|
||||
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
|
||||
}
|
||||
|
||||
rdb.LPush(ctx, key, time.Now().Format(timeFormat))
|
||||
rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))
|
||||
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,12 +75,12 @@ func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gi
|
||||
return func(c *gin.Context) {
|
||||
redisRateLimiter(c, maxRequestNum, duration, mark)
|
||||
}
|
||||
} else {
|
||||
// It's safe to call multi times.
|
||||
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
|
||||
return func(c *gin.Context) {
|
||||
memoryRateLimiter(c, maxRequestNum, duration, mark)
|
||||
}
|
||||
}
|
||||
|
||||
// It's safe to call multi times.
|
||||
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
|
||||
return func(c *gin.Context) {
|
||||
memoryRateLimiter(c, maxRequestNum, duration, mark)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,11 +95,3 @@ func GlobalAPIRateLimit() func(c *gin.Context) {
|
||||
func CriticalRateLimit() func(c *gin.Context) {
|
||||
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
|
||||
}
|
||||
|
||||
func DownloadRateLimit() func(c *gin.Context) {
|
||||
return rateLimitFactory(common.DownloadRateLimitNum, common.DownloadRateLimitDuration, "DW")
|
||||
}
|
||||
|
||||
func UploadRateLimit() func(c *gin.Context) {
|
||||
return rateLimitFactory(common.UploadRateLimitNum, common.UploadRateLimitDuration, "UP")
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"openflare/common"
|
||||
)
|
||||
|
||||
type turnstileCheckResponse struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func TurnstileCheck() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if common.TurnstileCheckEnabled {
|
||||
session := sessions.Default(c)
|
||||
turnstileChecked := session.Get("turnstile")
|
||||
if turnstileChecked != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
response := c.Query("turnstile")
|
||||
if response == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Turnstile token 为空",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
|
||||
"secret": {common.TurnstileSecretKey},
|
||||
"response": {response},
|
||||
"remoteip": {c.ClientIP()},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("turnstile verification request failed", "error", err)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
defer rawRes.Body.Close()
|
||||
var res turnstileCheckResponse
|
||||
err = json.NewDecoder(rawRes.Body).Decode(&res)
|
||||
if err != nil {
|
||||
slog.Error("decode turnstile verification response failed", "error", err)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !res.Success {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Turnstile 校验失败,请刷新重试!",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
session.Set("turnstile", true)
|
||||
err = session.Save()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无法保存会话信息,请重试",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -50,12 +50,6 @@ func CountApplyLogs(nodeID string) (total int64, err error) {
|
||||
return total, err
|
||||
}
|
||||
|
||||
func GetLatestApplyLog(nodeID string) (*ApplyLog, error) {
|
||||
log := &ApplyLog{}
|
||||
err := DB.Where("node_id = ?", nodeID).Order("id desc").First(log).Error
|
||||
return log, err
|
||||
}
|
||||
|
||||
func GetLatestApplyLogsByNodeIDs(nodeIDs []string) (map[string]*ApplyLog, error) {
|
||||
result := make(map[string]*ApplyLog)
|
||||
if len(nodeIDs) == 0 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"openflare/utils"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -56,14 +57,17 @@ type ExternalAccountView struct {
|
||||
}
|
||||
|
||||
func (source *AuthSource) Normalize() {
|
||||
source.Name = strings.TrimSpace(source.Name)
|
||||
source.Type = strings.TrimSpace(strings.ToLower(source.Type))
|
||||
source.DisplayName = strings.TrimSpace(source.DisplayName)
|
||||
source.ClientID = strings.TrimSpace(source.ClientID)
|
||||
source.ClientSecret = strings.TrimSpace(source.ClientSecret)
|
||||
source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL)
|
||||
source.Scopes = strings.TrimSpace(source.Scopes)
|
||||
source.IconURL = strings.TrimSpace(source.IconURL)
|
||||
source.Type = strings.ToLower(source.Type)
|
||||
utils.TrimStringFields(
|
||||
&source.Name,
|
||||
&source.Type,
|
||||
&source.DisplayName,
|
||||
&source.ClientID,
|
||||
&source.ClientSecret,
|
||||
&source.OpenIDDiscoveryURL,
|
||||
&source.Scopes,
|
||||
&source.IconURL,
|
||||
)
|
||||
if source.DisplayName == "" {
|
||||
source.DisplayName = source.Name
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"openflare/model/migrate"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyDatabaseSchemaVersion = 1
|
||||
currentDatabaseSchemaVersion = 14
|
||||
databaseSchemaVersionRowID = 1
|
||||
legacyDatabaseSchemaVersion = migrate.BaseDatabaseSchemaVersion
|
||||
databaseSchemaVersionRowID = 1
|
||||
)
|
||||
|
||||
var currentDatabaseSchemaVersion = migrate.CurrentVersion()
|
||||
|
||||
type DatabaseSchemaVersion struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Version int `json:"version" gorm:"not null"`
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"openflare/common"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Id int `json:"id"`
|
||||
Filename string `json:"filename" gorm:"index"`
|
||||
Description string `json:"description"`
|
||||
Uploader string `json:"uploader" gorm:"index"`
|
||||
UploaderId int `json:"uploader_id" gorm:"index"`
|
||||
Link string `json:"link" gorm:"unique;index"`
|
||||
UploadTime string `json:"upload_time"`
|
||||
DownloadCounter int `json:"download_counter"`
|
||||
}
|
||||
|
||||
func GetAllFiles(startIdx int, num int) ([]*File, error) {
|
||||
var files []*File
|
||||
var err error
|
||||
err = DB.Order("id desc").Limit(num).Offset(startIdx).Find(&files).Error
|
||||
return files, err
|
||||
}
|
||||
|
||||
func SearchFiles(keyword string) (files []*File, err error) {
|
||||
err = DB.Select([]string{"id", "filename", "description", "uploader", "uploader_id", "link", "upload_time", "download_counter"}).Where(
|
||||
"filename LIKE ? or uploader LIKE ? or uploader_id = ?", keyword+"%", keyword+"%", keyword).Find(&files).Error
|
||||
return files, err
|
||||
}
|
||||
|
||||
func (file *File) Insert() error {
|
||||
var err error
|
||||
err = DB.Create(file).Error
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete Make sure link is valid! Because we will use os.Remove to delete it!
|
||||
func (file *File) Delete() error {
|
||||
var err error
|
||||
err = DB.Delete(file).Error
|
||||
err = os.Remove(path.Join(common.UploadPath, file.Link))
|
||||
return err
|
||||
}
|
||||
|
||||
func UpdateDownloadCounter(link string) {
|
||||
DB.Model(&File{}).Where("link = ?", link).UpdateColumn("download_counter", gorm.Expr("download_counter + 1"))
|
||||
}
|
||||
@@ -2,16 +2,18 @@ package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
"log/slog"
|
||||
"openflare/common"
|
||||
"openflare/utils/security"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
@@ -24,7 +26,6 @@ type dbModel struct {
|
||||
|
||||
func registeredModels() []any {
|
||||
return []any{
|
||||
&File{},
|
||||
&User{},
|
||||
&AuthSource{},
|
||||
&ExternalAccount{},
|
||||
@@ -313,3 +314,10 @@ func CloseDB() error {
|
||||
err = sqlDB.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
func IsUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "unique")
|
||||
}
|
||||
|
||||
@@ -10,85 +10,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type legacyProxyRouteV4 struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Domain string `gorm:"uniqueIndex;size:255;not null"`
|
||||
OriginID *uint `gorm:"index"`
|
||||
OriginURL string `gorm:"size:2048;not null"`
|
||||
OriginHost string `gorm:"size:255"`
|
||||
Upstreams string `gorm:"type:text;not null;default:'[]'"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
|
||||
CertID *uint
|
||||
RedirectHTTP bool `gorm:"not null;default:false"`
|
||||
CacheEnabled bool `gorm:"not null;default:false"`
|
||||
CachePolicy string `gorm:"size:32;not null;default:''"`
|
||||
CacheRules string `gorm:"type:text;not null;default:'[]'"`
|
||||
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
|
||||
Remark string `gorm:"size:255"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (legacyProxyRouteV4) TableName() string {
|
||||
return "proxy_routes"
|
||||
}
|
||||
|
||||
type legacyProxyRouteV5 struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
SiteName string `gorm:"size:255;not null;default:''"`
|
||||
Domain string `gorm:"uniqueIndex;size:255;not null"`
|
||||
Domains string `gorm:"type:text;not null;default:'[]'"`
|
||||
OriginID *uint `gorm:"index"`
|
||||
OriginURL string `gorm:"size:2048;not null"`
|
||||
OriginHost string `gorm:"size:255"`
|
||||
Upstreams string `gorm:"type:text;not null;default:'[]'"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
|
||||
CertID *uint
|
||||
RedirectHTTP bool `gorm:"not null;default:false"`
|
||||
CacheEnabled bool `gorm:"not null;default:false"`
|
||||
CachePolicy string `gorm:"size:32;not null;default:''"`
|
||||
CacheRules string `gorm:"type:text;not null;default:'[]'"`
|
||||
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
|
||||
Remark string `gorm:"size:255"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (legacyProxyRouteV5) TableName() string {
|
||||
return "proxy_routes"
|
||||
}
|
||||
|
||||
type legacyProxyRouteV6 struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
SiteName string `gorm:"size:255;not null;default:''"`
|
||||
Domain string `gorm:"uniqueIndex;size:255;not null"`
|
||||
Domains string `gorm:"type:text;not null;default:'[]'"`
|
||||
OriginID *uint `gorm:"index"`
|
||||
OriginURL string `gorm:"size:2048;not null"`
|
||||
OriginHost string `gorm:"size:255"`
|
||||
Upstreams string `gorm:"type:text;not null;default:'[]'"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
|
||||
CertID *uint
|
||||
RedirectHTTP bool `gorm:"not null;default:false"`
|
||||
LimitConnPerServer int `gorm:"not null;default:0"`
|
||||
LimitConnPerIP int `gorm:"not null;default:0"`
|
||||
LimitRate string `gorm:"size:32;not null;default:''"`
|
||||
CacheEnabled bool `gorm:"not null;default:false"`
|
||||
CachePolicy string `gorm:"size:32;not null;default:''"`
|
||||
CacheRules string `gorm:"type:text;not null;default:'[]'"`
|
||||
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
|
||||
Remark string `gorm:"size:255"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (legacyProxyRouteV6) TableName() string {
|
||||
return "proxy_routes"
|
||||
}
|
||||
|
||||
type legacyProxyRouteV7 struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
SiteName string `gorm:"size:255;not null;default:''"`
|
||||
@@ -365,159 +286,6 @@ func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateMigratesObservabilityShardsToID(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-observability-shards.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := autoMigrateAll(db); err != nil {
|
||||
t.Fatalf("auto migrate db: %v", err)
|
||||
}
|
||||
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||
t.Fatalf("auto migrate schema metadata: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := db.Table("node_metric_snapshots_00").Create(&NodeMetricSnapshot{
|
||||
ID: 1,
|
||||
NodeID: "node-a",
|
||||
CapturedAt: now.Add(-2 * time.Minute),
|
||||
CPUUsagePercent: 22,
|
||||
MemoryUsedBytes: 2,
|
||||
MemoryTotalBytes: 8,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed metric snapshot shard 00: %v", err)
|
||||
}
|
||||
if err := db.Table("node_metric_snapshots_01").Create(&NodeMetricSnapshot{
|
||||
ID: 1,
|
||||
NodeID: "node-b",
|
||||
CapturedAt: now.Add(-time.Minute),
|
||||
CPUUsagePercent: 44,
|
||||
MemoryUsedBytes: 4,
|
||||
MemoryTotalBytes: 8,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed metric snapshot shard 01: %v", err)
|
||||
}
|
||||
if err := db.Table("node_request_reports_00").Create(&NodeRequestReport{
|
||||
ID: 1,
|
||||
NodeID: "node-a",
|
||||
WindowStartedAt: now.Add(-3 * time.Minute),
|
||||
WindowEndedAt: now.Add(-2 * time.Minute),
|
||||
RequestCount: 12,
|
||||
ErrorCount: 1,
|
||||
UniqueVisitorCount: 6,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed request report shard 00: %v", err)
|
||||
}
|
||||
if err := db.Table("node_request_reports_01").Create(&NodeRequestReport{
|
||||
ID: 1,
|
||||
NodeID: "node-b",
|
||||
WindowStartedAt: now.Add(-2 * time.Minute),
|
||||
WindowEndedAt: now.Add(-time.Minute),
|
||||
RequestCount: 21,
|
||||
ErrorCount: 2,
|
||||
UniqueVisitorCount: 9,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed request report shard 01: %v", err)
|
||||
}
|
||||
if err := db.Table("node_access_logs_00").Create(&NodeAccessLog{
|
||||
ID: 1,
|
||||
NodeID: "node-a",
|
||||
LoggedAt: now.Add(-90 * time.Second),
|
||||
RemoteAddr: "203.0.113.10",
|
||||
Host: "a.example.com",
|
||||
Path: "/alpha",
|
||||
StatusCode: 200,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed access log shard 00: %v", err)
|
||||
}
|
||||
if err := db.Table("node_access_logs_01").Create(&NodeAccessLog{
|
||||
ID: 1,
|
||||
NodeID: "node-b",
|
||||
LoggedAt: now.Add(-60 * time.Second),
|
||||
RemoteAddr: "203.0.113.11",
|
||||
Host: "b.example.com",
|
||||
Path: "/beta",
|
||||
StatusCode: 502,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed access log shard 01: %v", err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, 2); err != nil {
|
||||
t.Fatalf("save schema version: %v", err)
|
||||
}
|
||||
|
||||
previousDB := DB
|
||||
DB = db
|
||||
t.Cleanup(func() {
|
||||
DB = previousDB
|
||||
})
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("expected migrated database to keep schema version record")
|
||||
}
|
||||
if version != currentDatabaseSchemaVersion {
|
||||
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
|
||||
}
|
||||
|
||||
for _, baseTable := range shardedObservabilityBaseTables() {
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if db.Migrator().HasTable(legacyTable) {
|
||||
t.Fatalf("expected legacy shard table %s to be removed", legacyTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshots, err := ListMetricSnapshotsSince(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMetricSnapshotsSince failed: %v", err)
|
||||
}
|
||||
if len(snapshots) != 2 {
|
||||
t.Fatalf("expected 2 migrated metric snapshots, got %+v", snapshots)
|
||||
}
|
||||
reports, err := ListRequestReportsSince(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRequestReportsSince failed: %v", err)
|
||||
}
|
||||
if len(reports) != 2 {
|
||||
t.Fatalf("expected 2 migrated request reports, got %+v", reports)
|
||||
}
|
||||
logs, err := ListNodeAccessLogs(NodeAccessLogQuery{Page: 0, PageSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNodeAccessLogs failed: %v", err)
|
||||
}
|
||||
if len(logs) != 2 {
|
||||
t.Fatalf("expected 2 migrated access logs, got %+v", logs)
|
||||
}
|
||||
|
||||
seenSnapshotIDs := make(map[uint]struct{}, len(snapshots))
|
||||
for _, item := range snapshots {
|
||||
if item == nil || item.ID == 0 {
|
||||
t.Fatalf("expected migrated metric snapshot to have a new non-zero id: %+v", item)
|
||||
}
|
||||
if _, exists := seenSnapshotIDs[item.ID]; exists {
|
||||
t.Fatalf("expected migrated metric snapshot ids to be unique, got duplicate %d", item.ID)
|
||||
}
|
||||
seenSnapshotIDs[item.ID] = struct{}{}
|
||||
targetTable := observabilityShardTableForID("node_metric_snapshots", item.ID)
|
||||
var count int64
|
||||
if err := db.Table(targetTable).Where("id = ?", item.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count migrated metric snapshot in target shard: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected migrated metric snapshot id %d to be stored in %s", item.ID, targetTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateOriginsSchemaBackfillsOrigins(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-origins.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
@@ -573,217 +341,6 @@ func TestMigrateOriginsSchemaBackfillsOrigins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateBackfillsProxyRouteSiteFields(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-proxy-route-sites.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||
t.Fatalf("auto migrate schema metadata: %v", err)
|
||||
}
|
||||
|
||||
for _, item := range registeredModels() {
|
||||
if _, ok := item.(*ProxyRoute); ok {
|
||||
continue
|
||||
}
|
||||
if err := db.AutoMigrate(item); err != nil {
|
||||
t.Fatalf("auto migrate supporting table: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(&legacyProxyRouteV4{}); err != nil {
|
||||
t.Fatalf("auto migrate legacy proxy_routes: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := db.Create(&legacyProxyRouteV4{
|
||||
Domain: "app.example.com",
|
||||
OriginURL: "https://origin-a.internal:8443",
|
||||
Upstreams: `["https://origin-a.internal:8443","https://origin-b.internal:8443"]`,
|
||||
Enabled: true,
|
||||
EnableHTTPS: false,
|
||||
RedirectHTTP: false,
|
||||
CacheEnabled: false,
|
||||
CachePolicy: "",
|
||||
CacheRules: `[]`,
|
||||
CustomHeaders: `[]`,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed legacy proxy route: %v", err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, 4); err != nil {
|
||||
t.Fatalf("save schema version: %v", err)
|
||||
}
|
||||
|
||||
previousDB := DB
|
||||
DB = db
|
||||
t.Cleanup(func() {
|
||||
DB = previousDB
|
||||
})
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
var route ProxyRoute
|
||||
if err := db.First(&route).Error; err != nil {
|
||||
t.Fatalf("query migrated proxy route: %v", err)
|
||||
}
|
||||
if route.SiteName != "app.example.com" {
|
||||
t.Fatalf("unexpected site_name after migration: %s", route.SiteName)
|
||||
}
|
||||
if route.Domain != "app.example.com" {
|
||||
t.Fatalf("unexpected domain mirror after migration: %s", route.Domain)
|
||||
}
|
||||
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(route.Domains), &domains); err != nil {
|
||||
t.Fatalf("decode migrated domains: %v", err)
|
||||
}
|
||||
if len(domains) != 1 || domains[0] != "app.example.com" {
|
||||
t.Fatalf("unexpected migrated domains: %#v", domains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteRateLimitFields(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-proxy-route-rate-limits.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||
t.Fatalf("auto migrate schema metadata: %v", err)
|
||||
}
|
||||
|
||||
for _, item := range registeredModels() {
|
||||
if _, ok := item.(*ProxyRoute); ok {
|
||||
continue
|
||||
}
|
||||
if err := db.AutoMigrate(item); err != nil {
|
||||
t.Fatalf("auto migrate supporting table: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(&legacyProxyRouteV5{}); err != nil {
|
||||
t.Fatalf("auto migrate legacy proxy_routes v5: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := db.Create(&legacyProxyRouteV5{
|
||||
SiteName: "main-site",
|
||||
Domain: "app.example.com",
|
||||
Domains: `["app.example.com","www.example.com"]`,
|
||||
OriginURL: "https://origin-a.internal:8443",
|
||||
Upstreams: `["https://origin-a.internal:8443"]`,
|
||||
Enabled: true,
|
||||
EnableHTTPS: false,
|
||||
RedirectHTTP: false,
|
||||
CacheEnabled: false,
|
||||
CachePolicy: "",
|
||||
CacheRules: `[]`,
|
||||
CustomHeaders: `[]`,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed legacy proxy route v5: %v", err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, 5); err != nil {
|
||||
t.Fatalf("save schema version: %v", err)
|
||||
}
|
||||
|
||||
previousDB := DB
|
||||
DB = db
|
||||
t.Cleanup(func() {
|
||||
DB = previousDB
|
||||
})
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
var route ProxyRoute
|
||||
if err := db.First(&route).Error; err != nil {
|
||||
t.Fatalf("query migrated proxy route: %v", err)
|
||||
}
|
||||
if route.LimitConnPerServer != 0 || route.LimitConnPerIP != 0 || route.LimitRate != "" {
|
||||
t.Fatalf("expected new rate limit fields to default to disabled values, got %+v", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteCertificateListFields(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-proxy-route-cert-ids.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||
t.Fatalf("auto migrate schema metadata: %v", err)
|
||||
}
|
||||
|
||||
for _, item := range registeredModels() {
|
||||
if _, ok := item.(*ProxyRoute); ok {
|
||||
continue
|
||||
}
|
||||
if err := db.AutoMigrate(item); err != nil {
|
||||
t.Fatalf("auto migrate supporting table: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(&legacyProxyRouteV6{}); err != nil {
|
||||
t.Fatalf("auto migrate legacy proxy_routes v6: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
certID := uint(9)
|
||||
if err := db.Create(&legacyProxyRouteV6{
|
||||
SiteName: "secure-site",
|
||||
Domain: "secure.example.com",
|
||||
Domains: `["secure.example.com","www.secure.example.com"]`,
|
||||
OriginURL: "https://origin-secure.internal:8443",
|
||||
Upstreams: `["https://origin-secure.internal:8443"]`,
|
||||
Enabled: true,
|
||||
EnableHTTPS: true,
|
||||
CertID: &certID,
|
||||
RedirectHTTP: true,
|
||||
LimitConnPerServer: 120,
|
||||
LimitConnPerIP: 12,
|
||||
LimitRate: "512k",
|
||||
CacheEnabled: false,
|
||||
CachePolicy: "",
|
||||
CacheRules: `[]`,
|
||||
CustomHeaders: `[]`,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed legacy proxy route v6: %v", err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, 6); err != nil {
|
||||
t.Fatalf("save schema version: %v", err)
|
||||
}
|
||||
|
||||
previousDB := DB
|
||||
DB = db
|
||||
t.Cleanup(func() {
|
||||
DB = previousDB
|
||||
})
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
var route ProxyRoute
|
||||
if err := db.First(&route).Error; err != nil {
|
||||
t.Fatalf("query migrated proxy route: %v", err)
|
||||
}
|
||||
if route.CertID == nil || *route.CertID != certID {
|
||||
t.Fatalf("expected cert_id mirror to be preserved, got %+v", route.CertID)
|
||||
}
|
||||
|
||||
var certIDs []uint
|
||||
if err := json.Unmarshal([]byte(route.CertIDs), &certIDs); err != nil {
|
||||
t.Fatalf("decode migrated cert_ids: %v", err)
|
||||
}
|
||||
if len(certIDs) != 1 || certIDs[0] != certID {
|
||||
t.Fatalf("unexpected migrated cert_ids: %#v", certIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteDomainCertificateFields(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-proxy-route-domain-cert-ids.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
@@ -883,3 +440,43 @@ func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *t
|
||||
t.Fatal("expected schema version to remain unset after failed validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDatabaseSchemaUpToDateAddsNodeIPManualOverride(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "node-ip-manual-override-migration.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := applyCurrentSchema(db, "sqlite"); err != nil {
|
||||
t.Fatalf("apply current schema: %v", err)
|
||||
}
|
||||
if err := ensureDefaultWAFRuleGroup(db); err != nil {
|
||||
t.Fatalf("ensure default waf rule group: %v", err)
|
||||
}
|
||||
if err := db.Migrator().DropColumn(&Node{}, "ip_manual_override"); err != nil {
|
||||
t.Fatalf("drop ip_manual_override column: %v", err)
|
||||
}
|
||||
if db.Migrator().HasColumn(&Node{}, "ip_manual_override") {
|
||||
t.Fatal("expected test database to simulate schema v14 without ip_manual_override")
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, 14); err != nil {
|
||||
t.Fatalf("save schema version: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasColumn(&Node{}, "ip_manual_override") {
|
||||
t.Fatal("expected migration to add nodes.ip_manual_override")
|
||||
}
|
||||
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("expected schema version record to exist")
|
||||
}
|
||||
if version != currentDatabaseSchemaVersion {
|
||||
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package migrate
|
||||
|
||||
// Versions 1 through 7 are treated as the historical baseline. There are no
|
||||
// supported deployments below v8, so new upgrades start from this base version.
|
||||
@@ -0,0 +1,52 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const BaseDatabaseSchemaVersion = 7
|
||||
|
||||
type Context interface {
|
||||
ApplyCurrentSchema(db *gorm.DB, backend string) error
|
||||
BackfillOriginsFromProxyRoutes(db *gorm.DB) error
|
||||
BackfillProxyRouteSiteFields(db *gorm.DB) error
|
||||
EnsureProxyRouteSiteNameUniqueIndex(db *gorm.DB) error
|
||||
BackfillProxyRouteCertificateFields(db *gorm.DB) error
|
||||
BackfillProxyRouteDomainCertificateFields(db *gorm.DB) error
|
||||
EnsureDefaultGitHubAuthSource(db *gorm.DB) error
|
||||
EnsureDefaultWAFRuleGroup(db *gorm.DB) error
|
||||
ValidateDatabaseSchemaVersion(db *gorm.DB, backend string, version int) error
|
||||
}
|
||||
|
||||
type Migration struct {
|
||||
FromVersion int
|
||||
ToVersion int
|
||||
Migrate func(ctx Context, db *gorm.DB, backend string) error
|
||||
Validate func(ctx Context, db *gorm.DB, backend string) error
|
||||
}
|
||||
|
||||
var registeredMigrations []Migration
|
||||
|
||||
func Register(migration Migration) {
|
||||
registeredMigrations = append(registeredMigrations, migration)
|
||||
}
|
||||
|
||||
func Migrations() []Migration {
|
||||
migrations := append([]Migration{}, registeredMigrations...)
|
||||
sort.Slice(migrations, func(i int, j int) bool {
|
||||
return migrations[i].FromVersion < migrations[j].FromVersion
|
||||
})
|
||||
return migrations
|
||||
}
|
||||
|
||||
func CurrentVersion() int {
|
||||
version := BaseDatabaseSchemaVersion
|
||||
for _, migration := range registeredMigrations {
|
||||
if migration.ToVersion > version {
|
||||
version = migration.ToVersion
|
||||
}
|
||||
}
|
||||
return version
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package migrate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMigrationsAreContinuousFromBaseVersion(t *testing.T) {
|
||||
migrations := Migrations()
|
||||
if len(migrations) == 0 {
|
||||
t.Fatal("expected at least one registered migration")
|
||||
}
|
||||
expectedFrom := BaseDatabaseSchemaVersion
|
||||
for _, migration := range migrations {
|
||||
if migration.FromVersion != expectedFrom {
|
||||
t.Fatalf("expected migration from v%d, got v%d -> v%d", expectedFrom, migration.FromVersion, migration.ToVersion)
|
||||
}
|
||||
if migration.ToVersion != migration.FromVersion+1 {
|
||||
t.Fatalf("expected one-step migration, got v%d -> v%d", migration.FromVersion, migration.ToVersion)
|
||||
}
|
||||
expectedFrom = migration.ToVersion
|
||||
}
|
||||
if CurrentVersion() != expectedFrom {
|
||||
t.Fatalf("unexpected current version: got %d want %d", CurrentVersion(), expectedFrom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// v10 升级内容:新增可配置认证源与第三方账号绑定,并迁移旧 GitHub 登录配置。
|
||||
// 背景说明:登录体系从固定 GitHub OAuth 字段演进为通用认证源模型,需要创建 auth_sources、external_accounts,并把旧用户 GitHub 绑定迁移到新表。
|
||||
package migrate
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func init() {
|
||||
Register(V10())
|
||||
}
|
||||
|
||||
func V10() Migration {
|
||||
return Migration{
|
||||
FromVersion: 9,
|
||||
ToVersion: 10,
|
||||
Migrate: migrateV10,
|
||||
Validate: validateV10,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV10(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.EnsureDefaultGitHubAuthSource(db)
|
||||
}
|
||||
|
||||
func validateV10(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ValidateDatabaseSchemaVersion(db, backend, 10)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// v11 升级内容:新增 ACME 账户、DNS 账户,并扩展证书 provider 字段。
|
||||
// 背景说明:证书申请能力从单一手工导入扩展到自动签发,需要持久化 ACME/DNS 凭据,并标记证书来源。
|
||||
package migrate
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func init() {
|
||||
Register(V11())
|
||||
}
|
||||
|
||||
func V11() Migration {
|
||||
return Migration{
|
||||
FromVersion: 10,
|
||||
ToVersion: 11,
|
||||
Migrate: migrateV11,
|
||||
Validate: validateV11,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV11(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ApplyCurrentSchema(db, backend)
|
||||
}
|
||||
|
||||
func validateV11(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ValidateDatabaseSchemaVersion(db, backend, 11)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// v12 升级内容:为 proxy_routes 增加 Basic Auth 相关字段。
|
||||
// 背景说明:站点级访问控制需要支持基础认证,因此在代理路由配置中持久化 Basic Auth 开关与凭据配置。
|
||||
package migrate
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func init() {
|
||||
Register(V12())
|
||||
}
|
||||
|
||||
func V12() Migration {
|
||||
return Migration{
|
||||
FromVersion: 11,
|
||||
ToVersion: 12,
|
||||
Migrate: migrateV12,
|
||||
Validate: validateV12,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV12(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ApplyCurrentSchema(db, backend)
|
||||
}
|
||||
|
||||
func validateV12(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ValidateDatabaseSchemaVersion(db, backend, 12)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// v13 升级内容:新增 WAF 规则组与站点绑定表,并创建默认全局规则组。
|
||||
// 背景说明:WAF 配置从零散站点字段演进为可复用规则组,需要全局规则组作为默认入口,并支持站点与规则组绑定。
|
||||
package migrate
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func init() {
|
||||
Register(V13())
|
||||
}
|
||||
|
||||
func V13() Migration {
|
||||
return Migration{
|
||||
FromVersion: 12,
|
||||
ToVersion: 13,
|
||||
Migrate: migrateV13,
|
||||
Validate: validateV13,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV13(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.EnsureDefaultWAFRuleGroup(db)
|
||||
}
|
||||
|
||||
func validateV13(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ValidateDatabaseSchemaVersion(db, backend, 13)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// v14 升级内容:为 WAF 规则组增加 PoW 策略字段。
|
||||
// 背景说明:PoW 能力从站点路由侧沉淀到 WAF 规则组中,便于统一按规则组管理人机挑战策略。
|
||||
package migrate
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func init() {
|
||||
Register(V14())
|
||||
}
|
||||
|
||||
func V14() Migration {
|
||||
return Migration{
|
||||
FromVersion: 13,
|
||||
ToVersion: 14,
|
||||
Migrate: migrateV14,
|
||||
Validate: validateV14,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV14(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.EnsureDefaultWAFRuleGroup(db)
|
||||
}
|
||||
|
||||
func validateV14(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ValidateDatabaseSchemaVersion(db, backend, 14)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// v15 升级内容:为 nodes 增加 ip_manual_override 字段。
|
||||
// 背景说明:管理端手动指定节点 IP 后,Agent 心跳不应继续覆盖该值,因此需要在节点表中记录 IP 是否由管理端锁定。
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type nodeV15 struct {
|
||||
IPManualOverride bool `gorm:"column:ip_manual_override;not null;default:false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(V15())
|
||||
}
|
||||
|
||||
func V15() Migration {
|
||||
return Migration{
|
||||
FromVersion: 14,
|
||||
ToVersion: 15,
|
||||
Migrate: migrateV15,
|
||||
Validate: validateV15,
|
||||
}
|
||||
}
|
||||
|
||||
func (nodeV15) TableName() string {
|
||||
return "nodes"
|
||||
}
|
||||
|
||||
func migrateV15(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ApplyCurrentSchema(db, backend)
|
||||
}
|
||||
|
||||
func validateV15(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 14); err != nil {
|
||||
return err
|
||||
}
|
||||
if db == nil || !db.Migrator().HasColumn(&nodeV15{}, "ip_manual_override") {
|
||||
return fmt.Errorf("column nodes.ip_manual_override is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// v8 升级内容:为 proxy_routes 增加域名级证书绑定字段 domain_cert_ids,并回填已有站点的证书映射。
|
||||
// 背景说明:v1-v7 已作为历史初始基线合并;v8 是当前保留逐版本升级链的起点,用于把早期站点级证书列表扩展为每个域名可独立绑定证书。
|
||||
package migrate
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func init() {
|
||||
Register(V8())
|
||||
}
|
||||
|
||||
func V8() Migration {
|
||||
return Migration{
|
||||
FromVersion: 7,
|
||||
ToVersion: 8,
|
||||
Migrate: migrateV8,
|
||||
Validate: validateV8,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV8(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.BackfillOriginsFromProxyRoutes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.BackfillProxyRouteSiteFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.EnsureProxyRouteSiteNameUniqueIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.BackfillProxyRouteCertificateFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.BackfillProxyRouteDomainCertificateFields(db)
|
||||
}
|
||||
|
||||
func validateV8(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ValidateDatabaseSchemaVersion(db, backend, 8)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// v9 升级内容:为 proxy_routes 增加 PoW 防护配置字段。
|
||||
// 背景说明:反向代理站点需要支持 Proof-of-Work 抗机器人能力,因此在路由配置中持久化 PoW 开关与策略,并沿用 v8 的证书与站点字段回填。
|
||||
package migrate
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func init() {
|
||||
Register(V9())
|
||||
}
|
||||
|
||||
func V9() Migration {
|
||||
return Migration{
|
||||
FromVersion: 8,
|
||||
ToVersion: 9,
|
||||
Migrate: migrateV9,
|
||||
Validate: validateV9,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV9(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := migrateV8(ctx, db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateV9(ctx Context, db *gorm.DB, backend string) error {
|
||||
return ctx.ValidateDatabaseSchemaVersion(db, backend, 9)
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
schemamigrate "openflare/model/migrate"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -20,6 +22,63 @@ type databaseSchemaMigration struct {
|
||||
validate func(db *gorm.DB, backend string) error
|
||||
}
|
||||
|
||||
type databaseSchemaMigrationContext struct{}
|
||||
|
||||
func (databaseSchemaMigrationContext) ApplyCurrentSchema(db *gorm.DB, backend string) error {
|
||||
return applyCurrentSchema(db, backend)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) BackfillOriginsFromProxyRoutes(db *gorm.DB) error {
|
||||
return backfillOriginsFromProxyRoutes(db)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) BackfillProxyRouteSiteFields(db *gorm.DB) error {
|
||||
return backfillProxyRouteSiteFields(db)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) EnsureProxyRouteSiteNameUniqueIndex(db *gorm.DB) error {
|
||||
return ensureProxyRouteSiteNameUniqueIndex(db)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) BackfillProxyRouteCertificateFields(db *gorm.DB) error {
|
||||
return backfillProxyRouteCertificateFields(db)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) BackfillProxyRouteDomainCertificateFields(db *gorm.DB) error {
|
||||
return backfillProxyRouteDomainCertificateFields(db)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) EnsureDefaultGitHubAuthSource(db *gorm.DB) error {
|
||||
return ensureDefaultGitHubAuthSource(db)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) EnsureDefaultWAFRuleGroup(db *gorm.DB) error {
|
||||
return ensureDefaultWAFRuleGroup(db)
|
||||
}
|
||||
|
||||
func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB, backend string, version int) error {
|
||||
switch version {
|
||||
case 7:
|
||||
return validateDatabaseSchemaV7(db, backend)
|
||||
case 8:
|
||||
return validateDatabaseSchemaV8(db, backend)
|
||||
case 9:
|
||||
return validateDatabaseSchemaV9(db, backend)
|
||||
case 10:
|
||||
return validateDatabaseSchemaV10(db, backend)
|
||||
case 11:
|
||||
return validateDatabaseSchemaV11(db, backend)
|
||||
case 12:
|
||||
return validateDatabaseSchemaV12(db, backend)
|
||||
case 13:
|
||||
return validateDatabaseSchemaV13(db, backend)
|
||||
case 14:
|
||||
return validateDatabaseSchemaV14(db, backend)
|
||||
default:
|
||||
return fmt.Errorf("database schema validation for v%d is not defined", version)
|
||||
}
|
||||
}
|
||||
|
||||
func autoMigrateSchemaMetadata(db *gorm.DB) error {
|
||||
for _, item := range schemaMetadataModels() {
|
||||
if err := db.AutoMigrate(item); err != nil {
|
||||
@@ -789,225 +848,6 @@ func uintSlicesEqualForMigration(left []uint, right []uint) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func renameLegacyObservabilityShardTables(db *gorm.DB) error {
|
||||
for _, baseTable := range shardedObservabilityBaseTables() {
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if db.Migrator().HasTable(legacyTable) {
|
||||
return fmt.Errorf("legacy sharded table %s already exists", legacyTable)
|
||||
}
|
||||
if !db.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
if err := db.Migrator().RenameTable(table, legacyTable); err != nil {
|
||||
return fmt.Errorf("rename sharded table %s to %s failed: %w", table, legacyTable, err)
|
||||
}
|
||||
if err := dropLegacyObservabilitySecondaryIndexes(db, legacyTable); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropLegacyObservabilitySecondaryIndexes(db *gorm.DB, table string) error {
|
||||
db = sessionIgnoringSharding(db)
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
backend := baseDialector(db).Name()
|
||||
indexes := make([]string, 0)
|
||||
switch backend {
|
||||
case "sqlite":
|
||||
if err := db.Raw(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name LIKE 'idx_%'`,
|
||||
table,
|
||||
).Scan(&indexes).Error; err != nil {
|
||||
return fmt.Errorf("list indexes for %s failed: %w", table, err)
|
||||
}
|
||||
case "postgres":
|
||||
if err := db.Raw(
|
||||
`SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND tablename = ? AND indexname LIKE 'idx_%'`,
|
||||
table,
|
||||
).Scan(&indexes).Error; err != nil {
|
||||
return fmt.Errorf("list indexes for %s failed: %w", table, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported database backend %s", backend)
|
||||
}
|
||||
for _, indexName := range indexes {
|
||||
if err := db.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, indexName)).Error; err != nil {
|
||||
return fmt.Errorf("drop legacy index %s failed: %w", indexName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func autoMigrateObservabilityShardTables(db *gorm.DB) error {
|
||||
db = sessionIgnoringSharding(db)
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
dialector := baseDialector(db)
|
||||
if dialector == nil {
|
||||
return fmt.Errorf("database dialector is nil")
|
||||
}
|
||||
type shardedTable struct {
|
||||
model any
|
||||
base string
|
||||
}
|
||||
tables := []shardedTable{
|
||||
{model: &NodeMetricSnapshot{}, base: "node_metric_snapshots"},
|
||||
{model: &NodeRequestReport{}, base: "node_request_reports"},
|
||||
{model: &NodeAccessLog{}, base: "node_access_logs"},
|
||||
}
|
||||
for _, item := range tables {
|
||||
for _, table := range observabilityShardTables(item.base) {
|
||||
tx := db.Table(table)
|
||||
if err := dialector.Migrator(tx).AutoMigrate(item.model); err != nil {
|
||||
return fmt.Errorf("auto migrate sharded table %s failed: %w", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropLegacyObservabilityShardTables(db *gorm.DB) error {
|
||||
db = sessionIgnoringSharding(db)
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
for _, baseTable := range shardedObservabilityBaseTables() {
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if !db.Migrator().HasTable(legacyTable) {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`DROP TABLE IF EXISTS "%s"`, legacyTable)).Error; err != nil {
|
||||
return fmt.Errorf("drop legacy sharded table %s failed: %w", legacyTable, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyNodeMetricSnapshots(db *gorm.DB) error {
|
||||
for _, table := range observabilityShardTables("node_metric_snapshots") {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if !db.Migrator().HasTable(legacyTable) {
|
||||
continue
|
||||
}
|
||||
var lastSeenID uint
|
||||
for {
|
||||
var rows []NodeMetricSnapshot
|
||||
query := db.Table(legacyTable).Order("id ASC").Limit(500)
|
||||
if lastSeenID > 0 {
|
||||
query = query.Where("id > ?", lastSeenID)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("query legacy sharded table %s failed: %w", legacyTable, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
lastSeenID = rows[len(rows)-1].ID
|
||||
grouped := make(map[string][]NodeMetricSnapshot, observabilityShardCount)
|
||||
for index := range rows {
|
||||
rows[index].ID = 0
|
||||
if err := assignObservabilityID(&rows[index].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
targetTable := observabilityShardTableForID("node_metric_snapshots", rows[index].ID)
|
||||
grouped[targetTable] = append(grouped[targetTable], rows[index])
|
||||
}
|
||||
for targetTable, batch := range grouped {
|
||||
if err := db.Table(targetTable).Create(&batch).Error; err != nil {
|
||||
return fmt.Errorf("write migrated rows into %s failed: %w", targetTable, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyNodeRequestReports(db *gorm.DB) error {
|
||||
for _, table := range observabilityShardTables("node_request_reports") {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if !db.Migrator().HasTable(legacyTable) {
|
||||
continue
|
||||
}
|
||||
var lastSeenID uint
|
||||
for {
|
||||
var rows []NodeRequestReport
|
||||
query := db.Table(legacyTable).Order("id ASC").Limit(500)
|
||||
if lastSeenID > 0 {
|
||||
query = query.Where("id > ?", lastSeenID)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("query legacy sharded table %s failed: %w", legacyTable, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
lastSeenID = rows[len(rows)-1].ID
|
||||
grouped := make(map[string][]NodeRequestReport, observabilityShardCount)
|
||||
for index := range rows {
|
||||
rows[index].ID = 0
|
||||
if err := assignObservabilityID(&rows[index].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
targetTable := observabilityShardTableForID("node_request_reports", rows[index].ID)
|
||||
grouped[targetTable] = append(grouped[targetTable], rows[index])
|
||||
}
|
||||
for targetTable, batch := range grouped {
|
||||
if err := db.Table(targetTable).Create(&batch).Error; err != nil {
|
||||
return fmt.Errorf("write migrated rows into %s failed: %w", targetTable, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyNodeAccessLogs(db *gorm.DB) error {
|
||||
for _, table := range observabilityShardTables("node_access_logs") {
|
||||
legacyTable := legacyObservabilityShardTableName(table)
|
||||
if !db.Migrator().HasTable(legacyTable) {
|
||||
continue
|
||||
}
|
||||
var lastSeenID uint
|
||||
for {
|
||||
var rows []NodeAccessLog
|
||||
query := db.Table(legacyTable).Order("id ASC").Limit(500)
|
||||
if lastSeenID > 0 {
|
||||
query = query.Where("id > ?", lastSeenID)
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return fmt.Errorf("query legacy sharded table %s failed: %w", legacyTable, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
lastSeenID = rows[len(rows)-1].ID
|
||||
grouped := make(map[string][]NodeAccessLog, observabilityShardCount)
|
||||
for index := range rows {
|
||||
rows[index].ID = 0
|
||||
if err := assignObservabilityID(&rows[index].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
targetTable := observabilityShardTableForID("node_access_logs", rows[index].ID)
|
||||
grouped[targetTable] = append(grouped[targetTable], rows[index])
|
||||
}
|
||||
for targetTable, batch := range grouped {
|
||||
if err := db.Table(targetTable).Create(&batch).Error; err != nil {
|
||||
return fmt.Errorf("write migrated rows into %s failed: %w", targetTable, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOriginAddressForMigration(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
@@ -1086,136 +926,6 @@ func backfillOriginsFromProxyRoutes(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV2 upgrades the legacy schema to the first versioned schema by
|
||||
// creating schema metadata, applying the current tables, and backfilling
|
||||
// compatibility columns.
|
||||
func migrateV2(db *gorm.DB, backend string) error {
|
||||
return applyCurrentSchema(db, backend)
|
||||
}
|
||||
|
||||
// migrateV3 upgrades observability shard tables from legacy ID layout to the
|
||||
// current ID-sharded layout and migrates existing shard data into the new tables.
|
||||
func migrateV3(db *gorm.DB, backend string) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
_ = backend
|
||||
if err := renameLegacyObservabilityShardTables(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := autoMigrateObservabilityShardTables(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyNodeMetricSnapshots(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyNodeRequestReports(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyNodeAccessLogs(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return dropLegacyObservabilityShardTables(db)
|
||||
}
|
||||
|
||||
// migrateV4 introduces the origins schema and backfills proxy route origin
|
||||
// references from existing origin_url values.
|
||||
func migrateV4(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return backfillOriginsFromProxyRoutes(db)
|
||||
}
|
||||
|
||||
// migrateV5 upgrades proxy_routes to website-level identity fields by
|
||||
// backfilling site_name and domains while keeping domain as the primary-domain
|
||||
// compatibility mirror.
|
||||
func migrateV5(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOriginsFromProxyRoutes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillProxyRouteSiteFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureProxyRouteSiteNameUniqueIndex(db)
|
||||
}
|
||||
|
||||
// migrateV6 adds structured website-level rate limit fields to proxy_routes.
|
||||
func migrateV6(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOriginsFromProxyRoutes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillProxyRouteSiteFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureProxyRouteSiteNameUniqueIndex(db)
|
||||
}
|
||||
|
||||
// migrateV7 adds structured website-level certificate lists to proxy_routes
|
||||
// while keeping cert_id as the primary certificate compatibility mirror.
|
||||
func migrateV7(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOriginsFromProxyRoutes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillProxyRouteSiteFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return backfillProxyRouteCertificateFields(db)
|
||||
}
|
||||
|
||||
// migrateV8 adds per-domain certificate assignments to proxy_routes while
|
||||
// keeping cert_ids as the website-level compatibility mirror.
|
||||
func migrateV8(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOriginsFromProxyRoutes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillProxyRouteSiteFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillProxyRouteCertificateFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return backfillProxyRouteDomainCertificateFields(db)
|
||||
}
|
||||
|
||||
// migrateV9 adds PoW (Proof-of-Work) anti-bot protection fields to proxy_routes.
|
||||
func migrateV9(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOriginsFromProxyRoutes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillProxyRouteSiteFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillProxyRouteCertificateFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return backfillProxyRouteDomainCertificateFields(db)
|
||||
}
|
||||
|
||||
func ensureDefaultGitHubAuthSource(db *gorm.DB) error {
|
||||
if db == nil || !db.Migrator().HasTable(&AuthSource{}) || !db.Migrator().HasTable(&ExternalAccount{}) {
|
||||
return nil
|
||||
@@ -1310,14 +1020,6 @@ func ensureDefaultGitHubAuthSource(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV10 adds configurable auth sources and external account bindings.
|
||||
func migrateV10(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureDefaultGitHubAuthSource(db)
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV9(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV8(db, backend); err != nil {
|
||||
return err
|
||||
@@ -1344,15 +1046,6 @@ func validateDatabaseSchemaV10(db *gorm.DB, backend string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV11 adds acme and dns accounts and extends tls_certificates.
|
||||
func migrateV11(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
// Default values will be applied by gorm for new columns automatically during AutoMigrate.
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV11(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV10(db, backend); err != nil {
|
||||
return err
|
||||
@@ -1369,15 +1062,6 @@ func validateDatabaseSchemaV11(db *gorm.DB, backend string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV12 adds basic authentication fields to proxy_routes.
|
||||
func migrateV12(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
// Default values will be applied by gorm for new columns automatically during AutoMigrate.
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV12(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV11(db, backend); err != nil {
|
||||
return err
|
||||
@@ -1423,14 +1107,6 @@ func ensureDefaultWAFRuleGroup(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV13 adds WAF rule groups and website bindings.
|
||||
func migrateV13(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureDefaultWAFRuleGroup(db)
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV13(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV12(db, backend); err != nil {
|
||||
return err
|
||||
@@ -1451,14 +1127,6 @@ func validateDatabaseSchemaV13(db *gorm.DB, backend string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV14 adds PoW policy fields to WAF rule groups.
|
||||
func migrateV14(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureDefaultWAFRuleGroup(db)
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV14(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV13(db, backend); err != nil {
|
||||
return err
|
||||
@@ -1473,21 +1141,41 @@ func validateDatabaseSchemaV14(db *gorm.DB, backend string) error {
|
||||
}
|
||||
|
||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
return []databaseSchemaMigration{
|
||||
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
|
||||
{fromVersion: 2, toVersion: 3, migrate: migrateV3, validate: validateDatabaseSchemaV3},
|
||||
{fromVersion: 3, toVersion: 4, migrate: migrateV4, validate: validateDatabaseSchemaV4},
|
||||
{fromVersion: 4, toVersion: 5, migrate: migrateV5, validate: validateDatabaseSchemaV5},
|
||||
{fromVersion: 5, toVersion: 6, migrate: migrateV6, validate: validateDatabaseSchemaV6},
|
||||
{fromVersion: 6, toVersion: 7, migrate: migrateV7, validate: validateDatabaseSchemaV7},
|
||||
{fromVersion: 7, toVersion: 8, migrate: migrateV8, validate: validateDatabaseSchemaV8},
|
||||
{fromVersion: 8, toVersion: 9, migrate: migrateV9, validate: validateDatabaseSchemaV9},
|
||||
{fromVersion: 9, toVersion: 10, migrate: migrateV10, validate: validateDatabaseSchemaV10},
|
||||
{fromVersion: 10, toVersion: 11, migrate: migrateV11, validate: validateDatabaseSchemaV11},
|
||||
{fromVersion: 11, toVersion: 12, migrate: migrateV12, validate: validateDatabaseSchemaV12},
|
||||
{fromVersion: 12, toVersion: 13, migrate: migrateV13, validate: validateDatabaseSchemaV13},
|
||||
{fromVersion: 13, toVersion: 14, migrate: migrateV14, validate: validateDatabaseSchemaV14},
|
||||
ctx := databaseSchemaMigrationContext{}
|
||||
migrations := []databaseSchemaMigration{}
|
||||
for _, item := range schemamigrate.Migrations() {
|
||||
external := item
|
||||
migrations = append(migrations, databaseSchemaMigration{
|
||||
fromVersion: external.FromVersion,
|
||||
toVersion: external.ToVersion,
|
||||
migrate: func(db *gorm.DB, backend string) error {
|
||||
return external.Migrate(ctx, db, backend)
|
||||
},
|
||||
validate: func(db *gorm.DB, backend string) error {
|
||||
return validateExternalDatabaseSchema(ctx, db, backend, external.ToVersion)
|
||||
},
|
||||
})
|
||||
}
|
||||
return migrations
|
||||
}
|
||||
|
||||
func validateExternalDatabaseSchema(ctx databaseSchemaMigrationContext, db *gorm.DB, backend string, targetVersion int) error {
|
||||
if targetVersion <= schemamigrate.BaseDatabaseSchemaVersion {
|
||||
return ctx.ValidateDatabaseSchemaVersion(db, backend, targetVersion)
|
||||
}
|
||||
for _, migration := range schemamigrate.Migrations() {
|
||||
if migration.ToVersion > targetVersion {
|
||||
continue
|
||||
}
|
||||
if err := migration.Validate(ctx, db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCurrentDatabaseSchema(db *gorm.DB, backend string) error {
|
||||
return validateExternalDatabaseSchema(databaseSchemaMigrationContext{}, db, backend, currentDatabaseSchemaVersion)
|
||||
}
|
||||
|
||||
func databaseSchemaMigrationMap() map[int]databaseSchemaMigration {
|
||||
@@ -1530,6 +1218,10 @@ 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)
|
||||
}
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
@@ -1575,7 +1267,7 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
|
||||
if err := ensureDefaultWAFRuleGroup(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDatabaseSchemaV13(db, backend); err != nil {
|
||||
if err := validateCurrentDatabaseSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
|
||||
|
||||
@@ -7,6 +7,7 @@ type Node struct {
|
||||
NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
IP string `json:"ip" gorm:"size:64;not null"`
|
||||
IPManualOverride bool `json:"ip_manual_override" gorm:"not null;default:false"`
|
||||
GeoName string `json:"geo_name" gorm:"size:128"`
|
||||
GeoLatitude *float64 `json:"geo_latitude"`
|
||||
GeoLongitude *float64 `json:"geo_longitude"`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user