Compare commits

...

18 Commits

Author SHA1 Message Date
ryan 3ec607106d [新增] 对接 Uptime Kuma 2026-06-03 12:17:46 +08:00
ryan f671a96d8c [新增] 对接 Uptime Kuma 2026-06-03 11:58:00 +08:00
ryan fe5cf9021f [优化] 更新Dockerfile,优化构建过程 2026-06-02 23:49:17 +08:00
ryan 1be2461716 [优化] 修复构建 2026-06-02 23:43:10 +08:00
ryan a0e9484e37 [优化] 重构数据库迁移逻辑,整合遗留和Goose迁移处理 2026-06-02 23:35:02 +08:00
ryan 4ca6f2957b [优化] 重构数据库迁移逻辑,整合遗留和Goose迁移处理 2026-06-02 22:27:04 +08:00
ryan dfd040a9de [优化] 确保所有管理的进程在停止时被正确取消和清理 2026-06-02 21:30:48 +08:00
ryan f29292dd81 [优化] 添加进程管理功能,支持PID文件处理和孤儿进程清理 2026-06-02 21:08:04 +08:00
ryan 4566fc1f53 [优化] 增强 WebSocket 处理逻辑,添加上下文取消支持和关闭机制
[优化] 重构 WebSocket 处理逻辑,添加消息处理接口和心跳机制
2026-06-02 19:37:03 +08:00
ryan 4e58bdd85b [优化] 重构 WebSocket 客户端,整合共享连接逻辑并简化代码 2026-06-02 17:35:32 +08:00
ryan c009b9e283 [优化] 修复增强 2026-06-02 17:24:12 +08:00
ryan 4e33e0e521 [优化] 添加进程重启机制和指数退避策略以增强稳定性 2026-06-02 17:18:14 +08:00
ryan 7252fb6285 [优化] 重构进程管理逻辑,添加自动重启和退避机制 2026-06-02 17:12:34 +08:00
ryan 2220e45989 [优化] 更新 Docker 部署命令,增加对 HTTP3的支持 2026-06-02 16:49:15 +08:00
ryan 6158a487cf [优化] 添加多语言支持的验证页面文本 2026-06-02 16:11:46 +08:00
ryan 9f9c609809 [优化] 添加 HTTP/3 支持配置选项 2026-06-02 16:07:44 +08:00
ryan e4c6ce9062 [优化] 添加自定义状态码匹配方法 2026-06-02 08:34:03 +08:00
ryan 81dd44c8fc [优化] 添加自定义状态码匹配方法 2026-06-02 08:31:39 +08:00
72 changed files with 4456 additions and 751 deletions
+8
View File
@@ -1,5 +1,6 @@
.git
.idea
.github
anubis-source
**/node_modules
**/.next
@@ -9,3 +10,10 @@ anubis-source
**/coverage
**/*.db
**/*.log
tmp
logs
.DS_Store
.env
.env.*
docker-compose*.yml
+1
View File
@@ -50,3 +50,4 @@ go.work.sum
*-source
*-source.*
.codex*
+1 -1
View File
@@ -112,7 +112,7 @@ Docker 部署可直接运行 Agent 镜像:
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 \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
+1 -1
View File
@@ -102,7 +102,7 @@ Docker 部署时直接运行内置 OpenResty 的 Agent 镜像:
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 \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
+2 -2
View File
@@ -168,7 +168,7 @@ Docker 部署是 Agent 推荐的部署方式。Docker 部署时直接运行 Agen
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 \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-v openflare-agent-data:/data \
-v ./agent.json:/etc/openflare/agent.json:ro \
ghcr.io/rain-kl/openflare-agent:latest
@@ -180,7 +180,7 @@ docker run -d --name openflare-agent --restart unless-stopped \
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 \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
+1 -1
View File
@@ -179,4 +179,4 @@ go build -o openflare-agent ./cmd/agent
4. 涉及配置、部署、API 或产品边界时同步更新文档。
5. 风险较高的修改补充测试或等效联调验证。
数据库结构变更必须提升数据库版本号,并补充从上一版本到新版本的显式迁移方法和校验逻辑。
数据库结构变更必须提升数据库版本号,并补充显式迁移方法和校验逻辑。v8-v17 保留为旧升级框架兼容链;v17 之后统一使用 goose,新的 goose 框架代码必须集中在 `openflare_server/model/goose` 包下;每次数据库升级都要在该包下新增独立的 `goose_<timestamp>_<description>.go` 文件,不得把具体迁移逻辑集中堆在 goose 注册入口中,也不得把新 goose 框架代码放回 `openflare_server/model` 根包。
+1 -1
View File
@@ -119,7 +119,7 @@ Agent 部署方式推荐使用 Docker 部署(即直接运行内置 OpenResty
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 \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-v openflare-agent-data:/data \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
+14 -7
View File
@@ -12,7 +12,7 @@
"rules": [
{
"name": "单 IP 404 高频扫描",
"expr": "request_count > 100 && status_404_ratio >= 0.8"
"expr": "request_count > 100 && StatusRatio(404) >= 0.8"
}
]
}
@@ -57,6 +57,13 @@ Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:
比例字段都是 `0` 到 `1` 之间的小数。80% 应写成 `0.8`,50% 应写成 `0.5`。
### 自定义状态码匹配方法
如果内置的 `status_404_count` 和 `status_404_ratio` 不能满足您的需求,您可以使用以下内置方法来匹配任意状态码的请求数与占比:
* **`StatusCount(code)`**: 获取当前 IP 在回看窗口内返回指定状态码的请求数(如 `StatusCount(403) > 10`)
* **`StatusRatio(code)`**: 获取当前 IP 在回看窗口内返回指定状态码的请求数占该 IP 总请求数的比例(如 `StatusRatio(502) >= 0.5`)
## Expr 常用写法
自动 IP 组使用 Expr 语法,当前表达式必须返回布尔值。
@@ -67,12 +74,12 @@ Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:
| --- | --- | --- |
| `>`、`>=`、`<`、`<=` | 数值比较 | `request_count > 100` |
| `==`、`!=` | 相等或不相等 | `ip != "127.0.0.1"` |
| `&&` | 并且 | `request_count > 100 && status_404_ratio >= 0.8` |
| `||` | 或者 | `status_404_ratio >= 0.8 || server_error_count > 20` |
| `&&` | 并且 | `request_count > 100 && StatusRatio(404) >= 0.8` |
| `||` | 或者 | `StatusRatio(404) >= 0.8 || server_error_count > 20` |
| `!` | 取反 | `!(ip == "127.0.0.1")` |
| `in` | 判断值是否在列表中 | `ip in ["203.0.113.10", "198.51.100.20"]` |
| `not in` | 判断值是否不在列表中 | `ip not in ["127.0.0.1"]` |
| `()` | 分组控制优先级 | `(request_count > 100 && status_404_ratio >= 0.8) || server_error_count > 50` |
| `()` | 分组控制优先级 | `(request_count > 100 && StatusRatio(404) >= 0.8) || server_error_count > 50` |
## 内置预设
@@ -81,7 +88,7 @@ Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:
```json
{
"name": "单 IP 404 高频扫描",
"expr": "request_count > 100 && status_404_ratio >= 0.8"
"expr": "request_count > 100 && StatusRatio(404) >= 0.8"
}
```
@@ -106,7 +113,7 @@ Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:
"rules": [
{
"name": "高频 404 扫描",
"expr": "request_count > 100 && status_404_ratio >= 0.8"
"expr": "request_count > 100 && StatusRatio(404) >= 0.8"
}
]
}
@@ -148,7 +155,7 @@ IP 直连访问异常:
"rules": [
{
"name": "排除可信 IP 的 404 扫描",
"expr": "ip not in [\"203.0.113.10\", \"198.51.100.20\"] && request_count > 100 && status_404_ratio >= 0.8"
"expr": "ip not in [\"203.0.113.10\", \"198.51.100.20\"] && request_count > 100 && StatusRatio(404) >= 0.8"
}
]
}
+1 -1
View File
@@ -48,7 +48,7 @@ IP 组是进行大批量 IP 过滤的基石。OpenFlare 提供了极富弹性的
#### 3. 自动 IP 组 (Automatic)
* **用途**:**最具杀伤力的防扫描、防爆破自动通道**。
* **配置**:类型选择「自动」-> 编写 Expr 日志聚合逻辑。你可以直接引用系统内置的预设:
* **单 IP 404 高频扫描**:`request_count > 100 && status_404_ratio >= 0.8` (单个 IP 最近一小时请求超 100 次且 404 响应占比超 80%)。
* **单 IP 404 高频扫描**:`request_count > 100 && StatusRatio(404) >= 0.8` (单个 IP 最近一小时请求超 100 次且 404 响应占比超 80%)。
* **单 IP 直连访问异常**:`ip_host_count > 50 && ip_host_ratio > 0.5` (绕过域名直接通过 IP 地址进行高频请求)。
* **测试与立即执行**:保存前可点击 **「测试规则」** 按钮预览当前日志窗口被命中的 IP。保存后可点击 **「立即执行」** 直接聚合日志并生成封禁名单。
+12 -8
View File
@@ -49,7 +49,9 @@ Frontend:
各组件和模块(Server、Agent、Frontend)的物理目录分层职责详见 [仓库结构](../design/repository.md)。在此结构下,开发必须遵守以下核心分层规则:
* **Server 开发规则**:禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
* **Server 开发规则**:
* 禁止在 `controller/` 堆积业务逻辑,禁止在 `middleware/` 实现业务流程,禁止为简单需求新增平台层抽象。
* **定时任务开发规则**:禁止将不同业务模块(如 Uptime Kuma 整合、WAF IP 同步等)的定时任务具体执行逻辑与状态堆积在单个 `cron.go` 文件中。各模块对应的定时任务结构体和运行逻辑必须在独立的 Go 文件中定义,`cron.go` 只允许承担统一注册、初始化与调度器启停的职责。
* **Agent 开发规则**:每个模块职责单一,外部命令调用集中封装,状态落盘与配置落盘分离。
* **Frontend 开发规则**:页面文件只负责获取路由参数、组织页面结构、调用 feature 组件;不应手写复杂 API 细节、复杂表单校验逻辑或维护大量彼此耦合的局部状态。
@@ -112,17 +114,19 @@ Frontend:
每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法。迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录。
v1-v7 视为历史初始基线,不再维护逐版本升级文件。从 v8 起,数据库迁移必须放在 `openflare_server/model/migrate` 目录中,并以目标版本命名文件,例如 `v16.go`。每个版本文件通过 `init()` 注册自己的迁移,当前数据库版本取已注册迁移的最大目标版本。不得为了整理文件而改变已发布 v8+ 迁移的语义。
v1-v7 视为历史初始基线,不再维护逐版本升级文件。v8-v17 是旧升级框架的兼容迁移链,只保留在 `openflare_server/model/migrate` 目录中用于老库升级。旧库启动时必须先按旧框架升级到 `legacyMigrationTerminalVersion`,再桥接到 goose;不得为了整理文件而改变已发布 v8-v17 迁移的语义。
从 v17 之后,数据库升级统一使用 goose。新的 goose provider、桥接逻辑、注册入口和具体迁移文件必须全部放在 `openflare_server/model/goose` 包下,`openflare_server/model` 根包只保留纯净实体类、旧框架兼容适配和必要的上下文注入。每次新增数据库升级都必须新建一个单独的 Go 文件,文件名使用 `openflare_server/model/goose/goose_<timestamp>_<description>.go`,例如 `openflare_server/model/goose/goose_202606020001_add_node_capabilities_json.go`。迁移文件必须同时包含该版本的 goose migration 构造函数、升级逻辑和校验逻辑;`model/goose/migrations.go` 只能作为注册入口和公共构造工具,禁止把具体迁移逻辑集中堆放在该文件中。
执行数据库升级时必须按以下步骤完成:
1. 判断是否需要升级数据库版本:凡是新增/删除/重命名表、字段、索引、约束、列类型、分表规则,或改变持久化数据语义,都必须升级。
2. 新增 `openflare_server/model/migrate/vN.go`,其中 `N` 为目标版本号。文件头部必须包含注释,说明本次升级了什么内容,以及为什么需要升级。
3. 在 `vN.go` 中实现 `VN()`,并在 `init()` 中调用 `Register(VN())`。`FromVersion` 必须等于 `N-1`,`ToVersion` 必须等于 `N`。
4. 在 `migrateVN` 中写入升级逻辑。可通过 `Context` 调用 `ApplyCurrentSchema`、历史 backfill、默认数据初始化等公共能力;复杂数据修复必须显式处理,不得只依赖 `AutoMigrate`。
5. 在 `validateVN` 中写入升级后的校验逻辑。校验至少要覆盖新增表/字段/索引是否存在、关键默认数据是否存在、必要的数据回填是否成功。
6. 如果新迁移需要新的公共 backfill 或校验辅助函数,将其放在 `openflare_server/model/migrations.go` 或更合适的 model 文件中,并通过 `Context` 暴露给 `model/migrate`,避免子包反向 import `model` 造成循环依赖。
7. 补充迁移测试:至少覆盖从 `N-1` 老库升级到 `N` 后 schema version、字段/表结构、关键数据回填和校验结果。注册表连续性由 `model/migrate` 测试兜底,但具体业务迁移仍必须有测试。
2. 新增 `openflare_server/model/goose/goose_<timestamp>_<description>.go`,其中 `<timestamp>` 为 goose 版本号。文件头部或迁移构造函数附近必须包含注释,说明本次升级了什么内容,以及为什么需要升级。
3. 在该文件中实现独立迁移构造函数,并返回通过 `newGORMMigration(...)` 创建的 migration;随后只在 `openflare_server/model/goose/migrations.go` 的 `registeredMigrations(...)` 中新增一条注册项。
4. 在同一个单独迁移文件中写入升级逻辑。可通过 goose `Context` 调用 `ApplyCurrentSchema`、历史 backfill、默认数据初始化等公共能力;复杂数据修复必须显式处理,不得只依赖 `AutoMigrate`。
5. 在同一个单独迁移文件中写入升级后的校验逻辑。校验至少要覆盖新增表/字段/索引是否存在、关键默认数据是否存在、必要的数据回填是否成功。
6. 如果新迁移需要新的公共 backfill 或校验辅助函数,优先放在该迁移文件中;只有多个迁移共同复用时,才放到 `openflare_server/model/goose` 包内的公共文件中。不要把新 goose 框架代码放回 `openflare_server/model` 根包。
7. 补充迁移测试:至少覆盖从旧框架终点或上一 goose 版本升级后 schema version、字段/表结构、关键数据回填和校验结果。还应保留旧库从 v15/v17 桥接到 goose 的回归覆盖。
8. 同步更新设计/开发文档;如果管理端 API、配置项或用户可见行为变化,还要同步更新对应指南、配置参考和 Swagger 文档。
新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本。
+1 -17
View File
@@ -41,23 +41,7 @@ OpenFlare 的管理端 API 与 Agent API 都使用 JSON。
| `POST` | `/api/waf/ip-groups/:id/delete` | 删除 IP 组;已被规则组引用时会拒绝 |
| `POST` | `/api/waf/ip-groups/:id/sync` | 立即同步订阅型 IP 组或立即执行自动型 IP 组 |
IP 组 `type` 支持 `manual`、`automatic`、`subscription`。自动型 IP 组的 `auto_config` 是 JSON 对象,当前支持:
```json
{
"lookback_minutes": 60,
"rules": [
{
"name": "单 IP 404 高频扫描",
"expr": "request_count > 100 && status_404_ratio >= 0.8"
},
{
"name": "单 IP 直连访问异常",
"expr": "ip_host_count > 50 && ip_host_ratio > 0.5"
}
]
}
```
IP 组 `type` 支持 `manual`、`automatic`、`subscription`。自动型 IP 组的 `auto_config` 是 JSON 对象
自动规则使用 Expr 语法,表达式必须返回布尔值。规则按单个 IP 的请求日志聚合指标计算,可用字段包括 `ip`、`request_count`、`status_404_count`、`status_404_ratio`、`ip_host_count`、`ip_host_ratio`、`client_error_count`、`server_error_count`、`last_seen_unix`。完整语法和字段含义见 [WAF 自动 IP 组规则语法](../guide/waf-ip-group-expr.md)。订阅格式支持 `text` 与 `json`:文本格式按行解析 IP/IP 段并忽略空行和 `#` 开头的注释;JSON 格式可通过映射规则选择数组,默认读取根数组。
+11
View File
@@ -95,6 +95,17 @@ go run . --port 3000 --log-dir ./logs
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口 | `300` / `180` |
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 敏感接口限流次数 / 时间窗口 | `100` / `1200` |
| `UptimeKumaEnabled` | 是否启用 Uptime Kuma 自动同步 | `false` |
| `UptimeKumaUrl` | Uptime Kuma 实例地址 | 空 |
| `UptimeKumaUsername` | Uptime Kuma 登录用户名 | 空 |
| `UptimeKumaPassword` | Uptime Kuma 登录密码(写专,接口不回显) | 空 |
| `UptimeKumaMonitorScope` | 监控范围,支持 `all` (全部站点) 或 `selected` (选择站点) | `all` |
| `UptimeKumaSelectedSites` | 已选择监控站点的名称列表(英文逗号分隔) | 空 |
| `UptimeKumaSyncInterval` | 自动差分同步间隔(分钟) | `5` |
| `UptimeKumaInterval` | 监控心跳检测频率(秒) | `60` |
| `UptimeKumaRetry` | 监控最大重试次数 | `0` |
| `UptimeKumaRetryInterval` | 监控重试间隔时间(秒) | `60` |
| `UptimeKumaTimeout` | 监控请求超时断开时间(秒) | `48` |
说明:
+12 -2
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
@@ -10,12 +11,20 @@ ENV CGO_ENABLED=0 \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
WORKDIR /build
COPY openflare_server/go.mod openflare_server/go.sum ./openflare_server/
COPY openflare_agent/go.mod openflare_agent/go.sum ./openflare_agent/
WORKDIR /build/openflare_agent
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
WORKDIR /build
COPY openflare_server ./openflare_server
COPY openflare_agent ./openflare_agent
WORKDIR /build/openflare_agent
RUN go mod download
RUN go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o /build/openflare-agent ./cmd/agent
FROM openresty/openresty:alpine
@@ -32,3 +41,4 @@ COPY --from=builder /build/openflare-agent /usr/local/bin/openflare-agent
EXPOSE 80 443 18081
ENTRYPOINT ["/usr/local/bin/openflare-agent"]
CMD ["-config", "/etc/openflare/agent.json"]
+3 -5
View File
@@ -1,17 +1,15 @@
module openflare-agent
go 1.25.0
go 1.25.7
require (
golang.org/x/net v0.53.0
openflare v0.0.0
)
require openflare v0.0.0
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
)
+55 -32
View File
@@ -12,6 +12,7 @@ import (
"openflare-agent/internal/observability"
"openflare-agent/internal/protocol"
"openflare-agent/internal/state"
"openflare-agent/internal/wsclient"
)
type HeartbeatService interface {
@@ -211,53 +212,75 @@ func (r *Runner) startWebSocket(ctx context.Context, nodeID string) (<-chan erro
return done, nil
}
type agentWSHandler struct {
runner *Runner
conn protocol.WebSocketConnection
nodeID string
statusTicker *time.Ticker
}
func (h *agentWSHandler) OnConnect(ctx context.Context) error {
return h.runner.sendWebSocketStatus(ctx, h.nodeID, h.conn)
}
func (h *agentWSHandler) HandleMessage(ctx context.Context, msg wsclient.WSMessage) error {
var payloadBytes []byte
if msg.Payload != nil {
payloadBytes = []byte(msg.Payload)
}
protoMsg := protocol.WSMessage{
Type: msg.Type,
Payload: payloadBytes,
}
changed, err := h.runner.handleWebSocketMessage(ctx, protoMsg, h.conn)
if err != nil {
return err
}
if changed {
h.statusTicker.Reset(h.runner.Config.HeartbeatInterval.Duration())
}
return nil
}
func (h *agentWSHandler) OnClose(err error) {
slog.Error("agent ws receive failed", "error", err)
}
func (r *Runner) runWebSocket(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error {
slog.Debug("agent ws connected", "url", conn.URL(), "node_id", nodeID)
statusTicker := time.NewTicker(r.Config.HeartbeatInterval.Duration())
defer statusTicker.Stop()
messages := make(chan protocol.WSMessage, 8)
readDone := make(chan error, 1)
childCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Start status ticker sender in background
go func() {
for {
message, err := conn.Receive()
if err != nil {
readDone <- err
return
}
select {
case messages <- message:
case <-ctx.Done():
readDone <- ctx.Err()
case <-childCtx.Done():
return
case <-statusTicker.C:
if err := r.sendWebSocketStatus(childCtx, nodeID, conn); err != nil {
slog.Error("agent ws send status failed", "error", err)
_ = conn.Close()
return
}
}
}
}()
if err := r.sendWebSocketStatus(ctx, nodeID, conn); err != nil {
return err
wsConn, ok := conn.(*wsclient.Connection)
if !ok {
return errors.New("invalid websocket connection type")
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-readDone:
return err
case <-statusTicker.C:
if err := r.sendWebSocketStatus(ctx, nodeID, conn); err != nil {
return err
}
case message := <-messages:
changed, err := r.handleWebSocketMessage(ctx, message, conn)
if err != nil {
return err
}
if changed {
statusTicker.Reset(r.Config.HeartbeatInterval.Duration())
}
}
}
return wsConn.RunReceiveLoop(childCtx, &agentWSHandler{
runner: r,
conn: conn,
nodeID: nodeID,
statusTicker: statusTicker,
})
}
func (r *Runner) sendWebSocketStatus(ctx context.Context, nodeID string, conn protocol.WebSocketConnection) error {
+27 -8
View File
@@ -236,8 +236,27 @@ local challenge_info = cjson.encode({
pow_challenges:set(challenge_id, challenge_info, challenge_ttl)
local static_prefix = "/.within.website/x/cmd/anubis/static/"
local title = "Making sure you're not a bot!"
local accept_lang = ngx.var.http_accept_language or ""
local lang = "en"
if string.find(accept_lang, "zh") then
lang = "zh-CN"
end
local t_title = "Making sure you're not a bot!"
local t_status = "Loading..."
local t_protected = "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown."
local t_why = "Why am I seeing this?"
local t_why_desc = "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically."
local t_noscript = "JavaScript is required to pass this verification. Please enable JavaScript and reload."
if lang == "zh-CN" then
t_title = "正在确认你是不是机器人!"
t_status = "加载中..."
t_protected = "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。"
t_why = "为什么我会看到这个?"
t_why_desc = "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。"
t_noscript = "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。"
end
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say([[<!DOCTYPE html>
@@ -246,7 +265,7 @@ ngx.say([[<!DOCTYPE html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>]] .. title .. [[</title>
<title>]] .. t_title .. [[</title>
<link rel="stylesheet" href="]] .. static_prefix .. [[css/xess.css">
<style>
body,html{height:100%;display:flex;justify-content:center;align-items:center;margin-left:auto;margin-right:auto}
@@ -272,17 +291,17 @@ body,html{height:100%;display:flex;justify-content:center;align-items:center;mar
</head>
<body id="top">
<main>
<h1 id="title" class="centered-div">]] .. title .. [[</h1>
<h1 id="title" class="centered-div">]] .. t_title .. [[</h1>
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src="]] .. static_prefix .. [[img/pensive.webp?cacheBuster=openflare-pow">
<p id="status">Loading...</p>
<p>This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown.</p>
<p id="status">]] .. t_status .. [[</p>
<p>]] .. t_protected .. [[</p>
<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>
<details>
<summary>Why am I seeing this?</summary>
<p>OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically.</p>
<summary>]] .. t_why .. [[</summary>
<p>]] .. t_why_desc .. [[</p>
</details>
<noscript><p>JavaScript is required to pass this verification. Please enable JavaScript and reload.</p></noscript>
<noscript><p>]] .. t_noscript .. [[</p></noscript>
</div>
</main>
<script type="module" src="]] .. static_prefix .. [[js/main.mjs"></script>
+26 -110
View File
@@ -2,165 +2,81 @@ package wsclient
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/websocket"
"openflare-agent/internal/protocol"
shared "openflare/utils/wsclient"
)
type WSMessage = shared.WSMessage
type MessageHandler = shared.MessageHandler
type Client struct {
baseURL string
token string
timeout time.Duration
sharedClient *shared.Client
}
type Connection struct {
conn *websocket.Conn
url string
readTimeout time.Duration
sharedConn *shared.Connection
}
func New(baseURL string, token string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: strings.TrimSpace(token),
timeout: timeout,
sharedClient: shared.New(shared.Config{
BaseURL: baseURL,
Token: token,
Timeout: timeout,
HeaderKey: "X-Agent-Token",
WSPath: "/api/agent/ws",
}),
}
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
slog.Debug("agent ws client token updated")
c.sharedClient.SetToken(token)
}
func (c *Client) URL() string {
wsURL, err := buildWebsocketURL(c.baseURL)
if err != nil {
return ""
}
return wsURL
return c.sharedClient.URL()
}
func (c *Client) Connect(ctx context.Context) (protocol.WebSocketConnection, error) {
wsURL, err := buildWebsocketURL(c.baseURL)
conn, err := c.sharedClient.Connect(ctx)
if err != nil {
return nil, err
}
if strings.TrimSpace(c.token) == "" {
return nil, errors.New("agent ws token is empty")
}
origin := strings.TrimSpace(c.baseURL)
if origin == "" {
origin = "http://localhost"
}
config, err := websocket.NewConfig(wsURL, origin)
if err != nil {
return nil, err
}
config.Header = http.Header{}
config.Header.Set("X-Agent-Token", c.token)
if c.timeout > 0 {
config.Dialer = &net.Dialer{Timeout: c.timeout}
}
slog.Debug("agent ws dialing server", "url", wsURL)
conn, err := config.DialContext(ctx)
if err != nil {
return nil, err
}
slog.Debug("agent ws dial succeeded", "url", wsURL)
return &Connection{conn: conn, url: wsURL, readTimeout: websocketReadTimeout(c.timeout)}, nil
}
func buildWebsocketURL(baseURL string) (string, error) {
parsed, err := url.Parse(strings.TrimRight(baseURL, "/"))
if err != nil {
return "", err
}
switch parsed.Scheme {
case "http":
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
case "ws", "wss":
default:
return "", errors.New("server_url scheme must be http, https, ws, or wss")
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/agent/ws"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
return &Connection{sharedConn: conn}, nil
}
func (conn *Connection) URL() string {
if conn == nil {
if conn == nil || conn.sharedConn == nil {
return ""
}
return conn.url
return conn.sharedConn.URL
}
func (conn *Connection) SendStatus(payload protocol.NodePayload) error {
if conn == nil || conn.conn == nil {
return errors.New("agent ws connection is nil")
}
slog.Debug("agent ws sending status",
"node_id", payload.NodeID,
"current_version", payload.CurrentVersion,
"openresty_status", payload.OpenrestyStatus,
)
return websocket.JSON.Send(conn.conn, protocol.WSOutboundMessage{
Type: protocol.WSMessageTypeStatus,
Payload: payload,
})
return conn.sharedConn.SendMessage(protocol.WSMessageTypeStatus, payload)
}
func (conn *Connection) SendPong() error {
if conn == nil || conn.conn == nil {
return errors.New("agent ws connection is nil")
}
slog.Debug("agent ws sending pong")
return websocket.JSON.Send(conn.conn, protocol.WSOutboundMessage{
Type: protocol.WSMessageTypePong,
})
return conn.sharedConn.SendMessage(protocol.WSMessageTypePong, nil)
}
func (conn *Connection) Receive() (protocol.WSMessage, error) {
var message protocol.WSMessage
if conn == nil || conn.conn == nil {
return message, errors.New("agent ws connection is nil")
}
if conn.readTimeout > 0 {
_ = conn.conn.SetReadDeadline(time.Now().Add(conn.readTimeout))
}
err := websocket.JSON.Receive(conn.conn, &message)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
slog.Debug("agent ws receive timeout waiting for server message", "timeout", conn.readTimeout)
}
if err := conn.sharedConn.Receive(&message); err != nil {
return message, err
}
slog.Debug("agent ws received message", "type", message.Type)
return message, nil
}
func websocketReadTimeout(requestTimeout time.Duration) time.Duration {
timeout := requestTimeout * 6
if timeout < 75*time.Second {
return 75 * time.Second
}
return timeout
func (conn *Connection) RunReceiveLoop(ctx context.Context, handler shared.MessageHandler) error {
return conn.sharedConn.RunReceiveLoop(ctx, handler)
}
func (conn *Connection) Close() error {
if conn == nil || conn.conn == nil {
if conn == nil || conn.sharedConn == nil {
return nil
}
return conn.conn.Close()
return conn.sharedConn.Close()
}
+11 -3
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
@@ -5,13 +6,19 @@ FROM golang:1.25-alpine AS builder
ARG VERSION
WORKDIR /build
COPY openflare_server/go.mod openflare_server/go.sum /openflare_server/
COPY openflare_relay/go.mod openflare_relay/go.sum /openflare_relay/
WORKDIR /openflare_relay
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY openflare_relay/go.mod openflare_relay/go.sum ./
WORKDIR /build
COPY openflare_server /openflare_server
COPY openflare_relay /openflare_relay
WORKDIR /openflare_relay
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o openflare-relay ./cmd/relay
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-relay/internal/config.Version=$VERSION'" -o openflare-relay ./cmd/relay
# Final runtime image
FROM fatedier/frps:v0.69.0
@@ -25,3 +32,4 @@ ENV OPENFLARE_FRPS_PATH=/usr/bin/frps
ENV OPENFLARE_DATA_DIR=/var/lib/openflare-relay
ENTRYPOINT ["/usr/local/bin/openflare-relay"]
+13 -10
View File
@@ -1,13 +1,10 @@
module openflare-relay
go 1.25.0
go 1.25.7
replace openflare => ../openflare_server
require (
golang.org/x/net v0.55.0
openflare v0.0.0-00010101000000-000000000000
)
require openflare v0.0.0-00010101000000-000000000000
require (
github.com/bwmarrin/snowflake v0.3.0 // indirect
@@ -35,7 +32,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -44,18 +41,24 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pressly/goose/v3 v3.27.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
@@ -65,8 +68,8 @@ require (
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.25.10 // indirect
gorm.io/sharding v0.6.2 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+48 -15
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
@@ -55,8 +57,8 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@@ -64,16 +66,18 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -98,6 +102,8 @@ github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUK
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -106,6 +112,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
@@ -119,11 +127,14 @@ github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -139,6 +150,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
@@ -183,13 +196,33 @@ gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+128 -60
View File
@@ -19,6 +19,7 @@ type Manager struct {
frpsPath string
dataDir string
configPath string
pidPath string
agentToken string
mu sync.RWMutex
@@ -45,6 +46,7 @@ func NewManager(frpsPath string, dataDir string, agentToken string) *Manager {
frpsPath: frpsPath,
dataDir: dataDir,
configPath: filepath.Join(dataDir, "frps.toml"),
pidPath: filepath.Join(dataDir, "frps.pid"),
status: "unknown", // 启动阶段尚未获取配置,状态未知;避免首次 heartbeat 误报 frps_unhealthy
agentToken: agentToken,
}
@@ -102,19 +104,32 @@ func (m *Manager) UpdateConfig(cfg *service.RelayConfig) {
m.activeConfig.WebServerEnabled == cfg.WebServerEnabled {
if m.cmd == nil && !m.stopping {
slog.Warn("frps config unchanged but process is not running, restarting")
if err := m.restartProcess(); err != nil {
m.stopping = false
m.generation++
generation := m.generation
if err := m.renderConfig(cfg); err != nil {
slog.Error("failed to render frps config", "error", err)
m.status = "unhealthy"
m.lastError = err.Error()
slog.Error("failed to restart frps with unchanged config", "error", err)
return
}
go m.supervise(generation)
}
return
}
m.activeConfig = cfg
m.stopping = false
m.generation++
generation := m.generation
slog.Info("relay config updated, reloading frps")
if m.cmd != nil && m.cmd.Process != nil {
slog.Debug("stopping existing frps process")
_ = m.cmd.Process.Kill()
m.cmd = nil
}
if err := m.renderConfig(cfg); err != nil {
slog.Error("failed to render frps config", "error", err)
m.status = "unhealthy"
@@ -122,14 +137,7 @@ func (m *Manager) UpdateConfig(cfg *service.RelayConfig) {
return
}
if err := m.restartProcess(); err != nil {
slog.Error("failed to restart frps", "error", err)
m.status = "unhealthy"
m.lastError = err.Error()
} else {
m.status = "healthy"
m.lastError = ""
}
go m.supervise(generation)
}
func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
@@ -166,63 +174,100 @@ func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
return os.WriteFile(m.configPath, buf.Bytes(), 0644)
}
func (m *Manager) restartProcess() error {
m.generation++
generation := m.generation
if m.cmd != nil && m.cmd.Process != nil {
slog.Debug("stopping existing frps process")
_ = m.cmd.Process.Kill()
m.cmd = nil
}
return m.startProcessLocked(generation)
}
func (m *Manager) supervise(generation uint64) {
backoff := 1 * time.Second
const maxBackoff = 60 * time.Second
func (m *Manager) startProcessLocked(generation uint64) error {
cmd := exec.Command(m.frpsPath, "-c", m.configPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return err
}
m.cmd = cmd
m.status = "healthy"
m.lastError = ""
go func(c *exec.Cmd) {
err := c.Wait()
slog.Warn("frps process exited", "error", err)
for {
m.mu.Lock()
if m.cmd == c {
if m.stopping || m.generation != generation {
m.mu.Unlock()
return
}
ensureNoOrphanProcess(m.pidPath)
cmd := exec.Command(m.frpsPath, "-c", m.configPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Start()
if err != nil {
m.status = "unhealthy"
m.lastError = fmt.Sprintf("failed to start: %v", err)
slog.Error("failed to start frps", "error", err, "generation", generation)
m.mu.Unlock()
if !m.sleepOrInterrupt(generation, backoff) {
return
}
backoff = backoff * 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
_ = os.WriteFile(m.pidPath, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644)
m.cmd = cmd
m.status = "healthy"
m.lastError = ""
m.mu.Unlock()
startedAt := time.Now()
waitErr := cmd.Wait()
_ = os.Remove(m.pidPath)
m.mu.Lock()
if m.cmd == cmd {
m.cmd = nil
m.status = "unhealthy"
if err != nil {
m.lastError = err.Error()
if waitErr != nil {
m.lastError = fmt.Sprintf("exited with error: %v", waitErr)
} else {
m.lastError = "frps process exited"
m.lastError = "exited unexpectedly"
}
slog.Warn("frps process exited unexpectedly", "error", waitErr, "generation", generation)
}
shouldContinue := !m.stopping && m.generation == generation
m.mu.Unlock()
if !shouldContinue {
return
}
if time.Since(startedAt) >= 10*time.Second {
backoff = 1 * time.Second
}
if !m.sleepOrInterrupt(generation, backoff) {
return
}
backoff = backoff * 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
func (m *Manager) sleepOrInterrupt(generation uint64, d time.Duration) bool {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
select {
case <-ticker.C:
m.mu.RLock()
interrupted := m.stopping || m.generation != generation
m.mu.RUnlock()
if interrupted {
return false
}
}
shouldRestart := !m.stopping && m.generation == generation
m.mu.Unlock()
if !shouldRestart {
return
}
time.Sleep(2 * time.Second)
m.mu.Lock()
defer m.mu.Unlock()
if m.stopping || m.generation != generation {
return
}
slog.Warn("restarting frps after unexpected exit")
if err := m.startProcessLocked(generation); err != nil {
m.status = "unhealthy"
m.lastError = err.Error()
slog.Error("failed to auto restart frps", "error", err)
}
}(cmd)
return nil
}
return true
}
func (m *Manager) Stop() {
@@ -234,5 +279,28 @@ func (m *Manager) Stop() {
_ = m.cmd.Process.Kill()
m.cmd = nil
}
_ = os.Remove(m.pidPath)
m.status = "unhealthy"
}
func ensureNoOrphanProcess(pidPath string) {
data, err := os.ReadFile(pidPath)
if err != nil {
return
}
var pid int
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
return
}
if pid <= 0 {
return
}
process, err := os.FindProcess(pid)
if err == nil && process != nil {
slog.Warn("attempting to kill potentially orphan process", "pid", pid, "pid_path", pidPath)
_ = process.Kill()
// Wait a little bit to ensure the OS has reclaimed ports
time.Sleep(500 * time.Millisecond)
}
_ = os.Remove(pidPath)
}
@@ -0,0 +1,345 @@
package frps
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"openflare/service"
)
// Helper to write control file for the dummy script
func writeControl(t *testing.T, dir string, exitCode int, delaySeconds int) {
controlPath := filepath.Join(dir, "control.txt")
content := fmt.Sprintf("%d %d\n", exitCode, delaySeconds)
err := os.WriteFile(controlPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write control file: %v", err)
}
}
// Setup a dummy executable script that reads control.txt to decide exit code and sleep duration
func setupDummyScript(t *testing.T) (string, string) {
dir := t.TempDir()
scriptPath := filepath.Join(dir, "dummy_frps")
// On macOS/Linux, we write a shell script
scriptContent := fmt.Sprintf(`#!/bin/sh
control_file="%s/control.txt"
EXIT_CODE=0
DELAY=0
if [ -f "$control_file" ]; then
read -r EXIT_CODE DELAY < "$control_file"
fi
if [ -n "$DELAY" ] && [ "$DELAY" -gt 0 ] 2>/dev/null; then
sleep "$DELAY"
fi
exit "${EXIT_CODE:-0}"
`, dir)
err := os.WriteFile(scriptPath, []byte(scriptContent), 0755)
if err != nil {
t.Fatalf("failed to write dummy script: %v", err)
}
return scriptPath, dir
}
// Helper to poll for status to eliminate timing flakiness in tests
func assertStatusEventually(t *testing.T, m *Manager, expectedStatus string, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
rt := m.GetRuntimeStatus()
if rt.Status == expectedStatus {
return
}
time.Sleep(50 * time.Millisecond)
}
rt := m.GetRuntimeStatus()
t.Fatalf("expected status eventually %s, got %s (err: %s)", expectedStatus, rt.Status, rt.LastError)
}
func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
t.Helper()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-time.After(timeout):
t.Fatalf("expected process pid=%d to exit within %s", cmd.Process.Pid, timeout)
case <-done:
}
}
func TestStartProcessSuccess(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 5) // exit code 0, sleep 5s
m := NewManager(scriptPath, dir, "agent-token")
defer m.Stop()
cfg := &service.RelayConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
AuthToken: "test-auth",
WebServerEnabled: false,
}
m.UpdateConfig(cfg)
assertStatusEventually(t, m, "healthy", 2*time.Second)
rt := m.GetRuntimeStatus()
if !rt.ProcessAlive {
t.Error("expected process to be alive")
}
}
func TestStartProcessFailureAndBackoff(t *testing.T) {
dir := t.TempDir()
invalidScriptPath := filepath.Join(dir, "non_existent_frps")
m := NewManager(invalidScriptPath, dir, "agent-token")
defer m.Stop()
cfg := &service.RelayConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
AuthToken: "test-auth",
WebServerEnabled: false,
}
m.UpdateConfig(cfg)
assertStatusEventually(t, m, "unhealthy", 2*time.Second)
rt := m.GetRuntimeStatus()
if !strings.Contains(rt.LastError, "failed to start") {
t.Errorf("expected error message containing 'failed to start', got %s", rt.LastError)
}
// Correct the path to dummy script
scriptPath, _ := setupDummyScript(t)
writeControl(t, filepath.Dir(scriptPath), 0, 5)
m.mu.Lock()
m.frpsPath = scriptPath
m.mu.Unlock()
// Wait for backoff retry (1s backoff)
assertStatusEventually(t, m, "healthy", 3*time.Second)
rt = m.GetRuntimeStatus()
if !rt.ProcessAlive {
t.Error("expected process to be alive now")
}
}
func TestUnexpectedExitAndAutorestart(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
// Start with immediate exit code 1
writeControl(t, dir, 1, 0)
m := NewManager(scriptPath, dir, "agent-token")
defer m.Stop()
cfg := &service.RelayConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
AuthToken: "test-auth",
WebServerEnabled: false,
}
m.UpdateConfig(cfg)
assertStatusEventually(t, m, "unhealthy", 2*time.Second)
rt := m.GetRuntimeStatus()
if !strings.Contains(rt.LastError, "exited with error") {
t.Errorf("expected exit error, got %s", rt.LastError)
}
// Change control to be healthy (runs for 5s, exit 0)
writeControl(t, dir, 0, 5)
// Wait for the retry to fire (backoff was 1s)
assertStatusEventually(t, m, "healthy", 3*time.Second)
}
func TestBackoffReset(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
// Rapid exit to increase backoff
writeControl(t, dir, 1, 0)
m := NewManager(scriptPath, dir, "agent-token")
defer m.Stop()
cfg := &service.RelayConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
AuthToken: "test-auth",
WebServerEnabled: false,
}
m.UpdateConfig(cfg)
// Crashed once, backoff is 2s
assertStatusEventually(t, m, "unhealthy", 2*time.Second)
// Now make it run successfully for 11 seconds (exit code 0, sleep 11s)
writeControl(t, dir, 0, 11)
// Wait for next retry to start running
assertStatusEventually(t, m, "healthy", 4*time.Second)
// Wait for process to run for 10.5 seconds to trigger backoff reset
time.Sleep(10500 * time.Millisecond)
// Now make it crash again (exit code 1, sleep 0s)
writeControl(t, dir, 1, 0)
// Wait for it to finish and crash
assertStatusEventually(t, m, "unhealthy", 3*time.Second)
// It crashed. Since it ran for > 10s, backoff should have been reset to 1s.
// We make it healthy again (exit code 0, sleep 5)
writeControl(t, dir, 0, 5)
// Wait 1.5 seconds. If backoff was reset to 1s, it should be healthy now.
assertStatusEventually(t, m, "healthy", 2*time.Second)
}
func TestImmediateRestartOnSameConfigDeadProcess(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
// Crashes immediately
writeControl(t, dir, 1, 0)
m := NewManager(scriptPath, dir, "agent-token")
defer m.Stop()
cfg := &service.RelayConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
AuthToken: "test-auth",
WebServerEnabled: false,
}
m.UpdateConfig(cfg)
// Let it crash
assertStatusEventually(t, m, "unhealthy", 2*time.Second)
// Make it start successfully
writeControl(t, dir, 0, 5)
// Send same config block to trigger immediate restart bypass of backoff sleep
m.UpdateConfig(cfg)
// Check if it started immediately
assertStatusEventually(t, m, "healthy", 2*time.Second)
}
func TestSupervisorGenerationInterrupt(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 10)
m := NewManager(scriptPath, dir, "agent-token")
defer m.Stop()
cfg := &service.RelayConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
AuthToken: "test-auth",
WebServerEnabled: false,
}
m.UpdateConfig(cfg)
assertStatusEventually(t, m, "healthy", 2*time.Second)
m.mu.Lock()
gen1 := m.generation
cmd1 := m.cmd
m.mu.Unlock()
if cmd1 == nil {
t.Fatal("expected active process")
}
// Update configuration with new bind port to trigger new generation
cfg2 := &service.RelayConfig{
BindPort: 7001,
VhostHTTPPort: 8080,
AuthToken: "test-auth",
WebServerEnabled: false,
}
m.UpdateConfig(cfg2)
assertStatusEventually(t, m, "healthy", 2*time.Second)
m.mu.Lock()
gen2 := m.generation
cmd2 := m.cmd
m.mu.Unlock()
if gen2 <= gen1 {
t.Errorf("expected generation incremented, got gen1=%d gen2=%d", gen1, gen2)
}
if cmd2 == cmd1 {
t.Error("expected old process killed and new command started")
}
// Verify old process is actually killed
var cmd1Finished int32
go func() {
_ = cmd1.Wait()
atomic.StoreInt32(&cmd1Finished, 1)
}()
time.Sleep(200 * time.Millisecond)
if atomic.LoadInt32(&cmd1Finished) != 1 {
t.Error("expected first process to be killed")
}
}
func TestUpdateConfigKillsOrphanProcessBeforeRestart(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 5)
m := NewManager(scriptPath, dir, "agent-token")
defer m.Stop()
orphan := exec.Command("sh", "-c", "sleep 30")
if err := orphan.Start(); err != nil {
t.Fatalf("failed to start orphan process: %v", err)
}
t.Cleanup(func() {
if orphan.Process != nil {
_ = orphan.Process.Kill()
}
})
if err := os.WriteFile(m.pidPath, []byte(fmt.Sprintf("%d", orphan.Process.Pid)), 0o644); err != nil {
t.Fatalf("failed to seed orphan pid file: %v", err)
}
cfg := &service.RelayConfig{
BindPort: 7000,
VhostHTTPPort: 8080,
AuthToken: "test-auth",
WebServerEnabled: false,
}
m.UpdateConfig(cfg)
assertCommandExitedEventually(t, orphan, 2*time.Second)
assertStatusEventually(t, m, "healthy", 2*time.Second)
}
+25 -56
View File
@@ -51,66 +51,35 @@ func (r *Runner) Run(ctx context.Context) error {
}
}
func (r *Runner) handleConnection(ctx context.Context, conn *wsclient.Connection) {
// Send pings at 2× heartbeat interval to keep the server-side read deadline
// from expiring (server closes the WS if no data arrives within ~30 s).
pingInterval := r.Config.HeartbeatInterval.Duration() * 2
pingTicker := time.NewTicker(pingInterval)
defer pingTicker.Stop()
type relayWSHandler struct {
runner *Runner
}
messages := make(chan service.WSMessage, 8)
readDone := make(chan error, 1)
go func() {
for {
msg, err := conn.Receive()
if err != nil {
readDone <- err
return
}
select {
case messages <- msg:
case <-ctx.Done():
readDone <- ctx.Err()
return
}
}
}()
func (h *relayWSHandler) OnConnect(ctx context.Context) error {
return nil
}
for {
select {
case <-ctx.Done():
return
case err := <-readDone:
slog.Error("relay ws receive failed", "error", err)
return
case <-pingTicker.C:
if err := conn.SendPing(); err != nil {
slog.Error("relay ws send ping failed", "error", err)
return
}
case msg := <-messages:
switch msg.Type {
case "ping":
_ = conn.SendPong()
case "pong":
slog.Debug("relay ws pong received")
case "relay_config":
payloadBytes, ok := msg.Payload.(json.RawMessage)
if !ok {
slog.Error("invalid relay_config payload type")
continue
}
var cfg service.RelayConfig
if err := json.Unmarshal(payloadBytes, &cfg); err != nil {
slog.Error("failed to unmarshal relay_config", "error", err)
continue
}
r.FrpsManager.UpdateConfig(&cfg)
default:
slog.Debug("ignored unknown ws message type", "type", msg.Type)
}
func (h *relayWSHandler) HandleMessage(ctx context.Context, msg wsclient.WSMessage) error {
switch msg.Type {
case "relay_config":
var cfg service.RelayConfig
if err := json.Unmarshal(msg.Payload, &cfg); err != nil {
slog.Error("failed to unmarshal relay_config", "error", err)
return nil
}
h.runner.FrpsManager.UpdateConfig(&cfg)
default:
slog.Debug("ignored unknown ws message type", "type", msg.Type)
}
return nil
}
func (h *relayWSHandler) OnClose(err error) {
slog.Error("relay ws receive failed", "error", err)
}
func (r *Runner) handleConnection(ctx context.Context, conn *wsclient.Connection) {
_ = conn.RunReceiveLoop(ctx, &relayWSHandler{runner: r})
}
func (r *Runner) sleepContext(ctx context.Context, d time.Duration) {
+27 -105
View File
@@ -3,151 +3,73 @@ package wsclient
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/websocket"
"openflare/service"
shared "openflare/utils/wsclient"
)
type WSMessage = shared.WSMessage
type MessageHandler = shared.MessageHandler
type Client struct {
baseURL string
token string
timeout time.Duration
sharedClient *shared.Client
}
type Connection struct {
conn *websocket.Conn
url string
readTimeout time.Duration
sharedConn *shared.Connection
}
func New(baseURL string, token string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: strings.TrimSpace(token),
timeout: timeout,
sharedClient: shared.New(shared.Config{
BaseURL: baseURL,
Token: token,
Timeout: timeout,
HeaderKey: "X-Agent-Token",
WSPath: "/api/relay/ws",
}),
}
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
slog.Debug("relay ws client token updated")
c.sharedClient.SetToken(token)
}
func (c *Client) Connect(ctx context.Context) (*Connection, error) {
wsURL, err := buildWebsocketURL(c.baseURL)
conn, err := c.sharedClient.Connect(ctx)
if err != nil {
return nil, err
}
if strings.TrimSpace(c.token) == "" {
return nil, errors.New("relay ws token is empty")
}
origin := strings.TrimSpace(c.baseURL)
if origin == "" {
origin = "http://localhost"
}
config, err := websocket.NewConfig(wsURL, origin)
if err != nil {
return nil, err
}
config.Header = http.Header{}
config.Header.Set("X-Agent-Token", c.token)
if c.timeout > 0 {
config.Dialer = &net.Dialer{Timeout: c.timeout}
}
slog.Debug("relay ws dialing server", "url", wsURL)
conn, err := config.DialContext(ctx)
if err != nil {
return nil, err
}
slog.Debug("relay ws dial succeeded", "url", wsURL)
return &Connection{conn: conn, url: wsURL, readTimeout: websocketReadTimeout(c.timeout)}, nil
}
func buildWebsocketURL(baseURL string) (string, error) {
parsed, err := url.Parse(strings.TrimRight(baseURL, "/"))
if err != nil {
return "", err
}
switch parsed.Scheme {
case "http":
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
case "ws", "wss":
default:
return "", errors.New("server_url scheme must be http, https, ws, or wss")
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/relay/ws"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
return &Connection{sharedConn: conn}, nil
}
func (conn *Connection) SendPing() error {
if conn == nil || conn.conn == nil {
return errors.New("relay ws connection is nil")
}
slog.Debug("relay ws sending ping")
return websocket.JSON.Send(conn.conn, service.WSMessage{
Type: "ping",
})
return conn.sharedConn.SendMessage("ping", nil)
}
func (conn *Connection) SendPong() error {
if conn == nil || conn.conn == nil {
return errors.New("relay ws connection is nil")
}
slog.Debug("relay ws sending pong")
return websocket.JSON.Send(conn.conn, service.WSMessage{
Type: "pong",
})
return conn.sharedConn.SendMessage("pong", nil)
}
func (conn *Connection) Receive() (service.WSMessage, error) {
var message service.WSMessage
if conn == nil || conn.conn == nil {
return message, errors.New("relay ws connection is nil")
}
if conn.readTimeout > 0 {
_ = conn.conn.SetReadDeadline(time.Now().Add(conn.readTimeout))
}
// Use custom json unmarshaling to handle any type
var raw struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload,omitempty"`
}
err := websocket.JSON.Receive(conn.conn, &raw)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
slog.Debug("relay ws receive timeout waiting for server message", "timeout", conn.readTimeout)
}
return message, err
if err := conn.sharedConn.Receive(&raw); err != nil {
return service.WSMessage{}, err
}
message.Type = raw.Type
message.Payload = raw.Payload
slog.Debug("relay ws received message", "type", message.Type)
return message, nil
return service.WSMessage{
Type: raw.Type,
Payload: raw.Payload,
}, nil
}
func websocketReadTimeout(requestTimeout time.Duration) time.Duration {
timeout := requestTimeout * 6
if timeout < 75*time.Second {
return 75 * time.Second
}
return timeout
func (conn *Connection) RunReceiveLoop(ctx context.Context, handler shared.MessageHandler) error {
return conn.sharedConn.RunReceiveLoop(ctx, handler)
}
func (conn *Connection) Close() error {
if conn == nil || conn.conn == nil {
return nil
}
return conn.conn.Close()
return conn.sharedConn.Close()
}
+11 -5
View File
@@ -1,10 +1,16 @@
.git
.github
node_modules
web/node_modules
web/.next
web/build
web/out
upload
dist
tmp
logs
.git
.github
upload
*.log
.DS_Store
.env
.env.*
docker-compose*.yml
+21 -21
View File
@@ -1,38 +1,38 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM node:20 AS builder
FROM node:20 AS web-builder
ARG VERSION
WORKDIR /build
COPY ./web/package.json ./
COPY ./web/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
RUN corepack enable
COPY ./web/package.json ./web/pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY ./web ./
RUN NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
FROM golang:1.25 AS builder2
RUN --mount=type=cache,id=next-cache,target=/build/.next/cache \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
FROM golang:1.25 AS go-builder
ARG VERSION
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
COPY --from=builder /build/build ./web/build
RUN go mod download
RUN go build -trimpath -ldflags "-s -w -X 'openflare/common.Version=$VERSION'" -o openflare
COPY --from=web-builder /build/build ./web/build
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath \
-ldflags "-s -w -X 'openflare/common.Version=$VERSION'" \
-o openflare
FROM alpine
RUN apk update \
&& apk upgrade \
&& apk add --no-cache ca-certificates tzdata \
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates 2>/dev/null || true
ENV PORT=3000
COPY --from=builder2 /build/openflare /
COPY --from=go-builder /build/openflare /openflare
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/openflare"]
+14
View File
@@ -57,6 +57,19 @@ var GeoIPProvider = "ipinfo"
var DatabaseAutoCleanupEnabled = false
var DatabaseAutoCleanupRetentionDays = 30
// Uptime Kuma integration settings
var UptimeKumaEnabled = false
var UptimeKumaUrl = ""
var UptimeKumaUsername = ""
var UptimeKumaPassword = ""
var UptimeKumaMonitorScope = "all" // "all" or "selected"
var UptimeKumaSelectedSites = "" // Comma-separated list of site names
var UptimeKumaSyncInterval = 5 // minutes
var UptimeKumaInterval = 60 // seconds
var UptimeKumaRetry = 0
var UptimeKumaRetryInterval = 60 // seconds
var UptimeKumaTimeout = 48 // seconds
// V5 OpenResty performance settings (hot-reloadable via Option table)
var OpenRestyDefaultServerReturnStatus = 421
@@ -77,6 +90,7 @@ var OpenRestyProxyConnectTimeout = 3
var OpenRestyProxySendTimeout = 60
var OpenRestyProxyReadTimeout = 60
var OpenRestyWebsocketEnabled = true
var OpenRestyHTTP3Enabled = true
var OpenRestyProxyRequestBufferingEnabled = false
var OpenRestyProxyBufferingEnabled = true
var OpenRestyProxyBuffers = "16 16k"
+59 -32
View File
@@ -100,6 +100,56 @@ func validateAgentOption(key string, value string) error {
}
}
func validateUptimeKumaOption(key string, value string, state map[string]string) error {
trimmed := strings.TrimSpace(value)
switch key {
case "UptimeKumaEnabled":
if err := validateBooleanOption(key, trimmed); err != nil {
return err
}
if trimmed == "true" {
url := strings.TrimSpace(state["UptimeKumaUrl"])
username := strings.TrimSpace(state["UptimeKumaUsername"])
password := strings.TrimSpace(state["UptimeKumaPassword"])
if url == "" {
return fmt.Errorf("启用 Uptime Kuma 时地址不能为空")
}
if username == "" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
if password == "" && common.UptimeKumaPassword == "" {
return fmt.Errorf("启用 Uptime Kuma 时密码不能为空")
}
}
case "UptimeKumaUsername":
if trimmed == "" && state["UptimeKumaEnabled"] == "true" {
return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空")
}
case "UptimeKumaPassword":
// No specific format checks needed
case "UptimeKumaUrl":
if trimmed != "" {
if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
return fmt.Errorf("Uptime Kuma 地址必须以 http:// 或 https:// 开头")
}
}
case "UptimeKumaMonitorScope":
if trimmed != "all" && trimmed != "selected" {
return fmt.Errorf("监控范围必须为全部站点 (all) 或选择站点 (selected)")
}
case "UptimeKumaSyncInterval", "UptimeKumaInterval", "UptimeKumaRetryInterval", "UptimeKumaTimeout":
if err := validatePositiveIntegerOption(key, trimmed); err != nil {
return err
}
case "UptimeKumaRetry":
intValue, err := strconv.Atoi(trimmed)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
}
}
return nil
}
func validateOpenRestyOption(key string, value string) error {
trimmed := strings.TrimSpace(value)
@@ -159,6 +209,7 @@ func validateOpenRestyOption(key string, value string) error {
return nil
case "OpenRestyEventsMultiAcceptEnabled",
"OpenRestyWebsocketEnabled",
"OpenRestyHTTP3Enabled",
"OpenRestyProxyRequestBufferingEnabled",
"OpenRestyProxyBufferingEnabled",
"OpenRestyGzipEnabled",
@@ -262,6 +313,9 @@ func validateOptionWithState(option model.Option, state map[string]string) error
if err := validateAgentOption(option.Key, option.Value); err != nil {
return err
}
if err := validateUptimeKumaOption(option.Key, option.Value, state); err != nil {
return err
}
return nil
}
@@ -291,9 +345,9 @@ func updateOptions(options []model.Option) error {
// @Router /api/option/ [get]
func GetOptions(c *gin.Context) {
var options []*model.Option
common.OptionMapRWMutex.Lock()
common.OptionMapRWMutex.RLock()
for k, v := range common.OptionMap {
if strings.Contains(k, "Token") || strings.Contains(k, "Secret") {
if strings.Contains(k, "Token") || strings.Contains(k, "Secret") || strings.Contains(k, "Password") {
continue
}
options = append(options, &model.Option{
@@ -301,7 +355,7 @@ func GetOptions(c *gin.Context) {
Value: utils.Interface2String(v),
})
}
common.OptionMapRWMutex.Unlock()
common.OptionMapRWMutex.RUnlock()
respondSuccess(c, options)
}
@@ -319,35 +373,8 @@ func UpdateOption(c *gin.Context) {
if !bindJSON(c, &option) {
return
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
respondFailure(c, "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!")
return
}
case "WeChatAuthEnabled":
if option.Value == "true" && common.WeChatServerAddress == "" {
respondFailure(c, "无法启用微信登录,请先填入微信登录相关配置信息!")
return
}
}
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
respondFailure(c, err.Error())
return
}
if err := validateAgentOption(option.Key, option.Value); err != nil {
state := buildOptionValidationState([]model.Option{option})
if err := validateOptionWithState(option, state); err != nil {
respondFailure(c, err.Error())
return
}
+55 -1
View File
@@ -1,6 +1,8 @@
package controller
import "testing"
import (
"testing"
)
func TestValidateOpenRestyOption(t *testing.T) {
testCases := []struct {
@@ -63,3 +65,55 @@ func TestValidateAgentOption(t *testing.T) {
t.Fatal("expected websocket upgrade option to reject non-boolean value")
}
}
func TestValidateUptimeKumaOption(t *testing.T) {
state := map[string]string{
"UptimeKumaUrl": "http://localhost:3001",
"UptimeKumaUsername": "admin",
"UptimeKumaPassword": "password",
}
testCases := []struct {
name string
key string
value string
wantErr bool
}{
{name: "enabled true", key: "UptimeKumaEnabled", value: "true"},
{name: "enabled false", key: "UptimeKumaEnabled", value: "false"},
{name: "enabled invalid", key: "UptimeKumaEnabled", value: "on", wantErr: true},
{name: "url http valid", key: "UptimeKumaUrl", value: "http://192.168.1.100:3001"},
{name: "url https valid", key: "UptimeKumaUrl", value: "https://kuma.example.com"},
{name: "url invalid", key: "UptimeKumaUrl", value: "kuma.example.com", wantErr: true},
{name: "scope all", key: "UptimeKumaMonitorScope", value: "all"},
{name: "scope selected", key: "UptimeKumaMonitorScope", value: "selected"},
{name: "scope invalid", key: "UptimeKumaMonitorScope", value: "none", wantErr: true},
{name: "sync interval valid", key: "UptimeKumaSyncInterval", value: "5"},
{name: "sync interval invalid", key: "UptimeKumaSyncInterval", value: "0", wantErr: true},
{name: "interval valid", key: "UptimeKumaInterval", value: "60"},
{name: "interval invalid", key: "UptimeKumaInterval", value: "-60", wantErr: true},
{name: "retry valid", key: "UptimeKumaRetry", value: "0"},
{name: "retry positive valid", key: "UptimeKumaRetry", value: "3"},
{name: "retry invalid", key: "UptimeKumaRetry", value: "-1", wantErr: true},
}
for _, tc := range testCases {
err := validateUptimeKumaOption(tc.key, tc.value, state)
if tc.wantErr && err == nil {
t.Fatalf("%s: expected error", tc.name)
}
if !tc.wantErr && err != nil {
t.Fatalf("%s: unexpected error: %v", tc.name, err)
}
}
// Test enabling Uptime Kuma when URL or credentials are empty in state
stateEmpty := map[string]string{
"UptimeKumaUrl": "",
"UptimeKumaUsername": "",
"UptimeKumaPassword": "",
}
if err := validateUptimeKumaOption("UptimeKumaEnabled", "true", stateEmpty); err == nil {
t.Fatal("expected error when enabling Uptime Kuma with empty URL/credentials in state")
}
}
+24
View File
@@ -0,0 +1,24 @@
package controller
import (
"openflare/service"
"github.com/gin-gonic/gin"
)
// SyncUptimeKuma godoc
// @Summary Manually trigger Uptime Kuma sync
// @Tags UptimeKuma
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/uptimekuma/sync [post]
func SyncUptimeKuma(c *gin.Context) {
err := service.SyncToUptimeKuma()
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccessMessage(c, "同步成功")
}
+11 -7
View File
@@ -1,7 +1,6 @@
module openflare
// +heroku goVersion go1.24
go 1.25.0
go 1.25.7
require (
github.com/bwmarrin/snowflake v0.3.0
@@ -17,6 +16,7 @@ require (
github.com/go-redis/redis/v8 v8.11.5
github.com/google/uuid v1.6.0
github.com/oschwald/maxminddb-golang v1.13.1
github.com/pressly/goose/v3 v3.27.1
github.com/robfig/cron/v3 v3.0.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
@@ -57,7 +57,7 @@ require (
github.com/gorilla/sessions v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -68,13 +68,17 @@ require (
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
@@ -85,8 +89,8 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+48 -15
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
@@ -87,8 +89,8 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@@ -99,8 +101,8 @@ github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
@@ -110,12 +112,14 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -152,6 +156,8 @@ github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -161,6 +167,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
@@ -175,13 +183,16 @@ github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -208,6 +219,8 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
@@ -287,13 +300,33 @@ gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+8
View File
@@ -26,6 +26,14 @@ func InitCronJobs() {
slog.Info("registered WAF IP group sync cron job")
}
// Register Uptime Kuma sync job (check every minute)
_, err = cronRunner.AddJob("* * * * *", &UptimeKumaSyncJob{})
if err != nil {
slog.Error("failed to register Uptime Kuma sync cron job", "error", err)
} else {
slog.Info("registered Uptime Kuma sync cron job")
}
cronRunner.Start()
}
+43
View File
@@ -0,0 +1,43 @@
package job
import (
"log/slog"
"openflare/common"
"openflare/service"
"sync"
"time"
)
var lastUptimeKumaSyncTime time.Time
var uptimeKumaSyncMutex sync.Mutex
type UptimeKumaSyncJob struct{}
func (j *UptimeKumaSyncJob) Run() {
if !common.UptimeKumaEnabled {
return
}
interval := common.UptimeKumaSyncInterval
if interval <= 0 {
interval = 5
}
if time.Since(lastUptimeKumaSyncTime) < time.Duration(interval)*time.Minute {
return
}
if !uptimeKumaSyncMutex.TryLock() {
slog.Warn("Uptime Kuma sync job is already running, skipping this scheduled run")
return
}
defer uptimeKumaSyncMutex.Unlock()
slog.Info("Starting scheduled Uptime Kuma sync")
if err := service.SyncToUptimeKuma(); err != nil {
slog.Error("Uptime Kuma sync failed", "error", err)
} else {
lastUptimeKumaSyncTime = time.Now()
slog.Info("Uptime Kuma sync completed successfully")
}
}
+2
View File
@@ -49,6 +49,8 @@ func main() {
gin.SetMode(gin.ReleaseMode)
}
// Initialize SQL Database
defer service.ShutdownWSHubs()
err := model.InitDB()
if err != nil {
slog.Error("initialize database failed", "error", err)
@@ -6,11 +6,14 @@ import (
)
const (
legacyDatabaseSchemaVersion = migrate.BaseDatabaseSchemaVersion
databaseSchemaVersionRowID = 1
legacyDatabaseSchemaVersion = migrate.BaseDatabaseSchemaVersion
legacyMigrationTerminalVersion = 17
databaseSchemaVersionRowID = 1
)
var currentDatabaseSchemaVersion = migrate.CurrentVersion()
// currentDatabaseSchemaVersion tracks the current physical schema validated by the
// legacy validator set. Goose owns only post-v17 migrations, and none exist yet.
var currentDatabaseSchemaVersion = legacyMigrationTerminalVersion
type DatabaseSchemaVersion struct {
ID uint `json:"id" gorm:"primaryKey"`
+223
View File
@@ -0,0 +1,223 @@
package goose
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"gorm.io/gorm"
)
type BridgeContext interface {
Context
AutoMigrateLegacySchemaMetadata(db *gorm.DB) error
InitializeFreshDatabaseSchema(db *gorm.DB, backend string) error
IsDatabaseEmpty(db *gorm.DB) (bool, error)
RepairCurrentSchemaState(db *gorm.DB, backend string) error
SaveLegacyDatabaseSchemaVersion(db *gorm.DB, version int) error
UpgradeLegacyDatabaseSchema(db *gorm.DB, backend string, version int) error
ValidateCurrentDatabaseSchema(db *gorm.DB, backend string) error
}
type schemaMigrationState int
const (
schemaMigrationStateFresh schemaMigrationState = iota
schemaMigrationStateLegacyOnly
schemaMigrationStateGooseOnly
schemaMigrationStateLegacyBootstrap
schemaMigrationStateMixed
)
func detectSchemaState(db *gorm.DB, ctx BridgeContext) (schemaMigrationState, error) {
hasLegacyTable := db.Migrator().HasTable("database_schema_versions")
hasGooseTable := db.Migrator().HasTable("goose_db_version")
switch {
case hasLegacyTable && hasGooseTable:
return schemaMigrationStateMixed, nil
case hasLegacyTable:
return schemaMigrationStateLegacyOnly, nil
case hasGooseTable:
return schemaMigrationStateGooseOnly, nil
}
empty, err := ctx.IsDatabaseEmpty(db)
if err != nil {
return 0, err
}
if empty {
return schemaMigrationStateFresh, nil
}
return schemaMigrationStateLegacyBootstrap, nil
}
func LoadDatabaseVersion(db *gorm.DB) (int, bool, error) {
if db == nil || !db.Migrator().HasTable("goose_db_version") {
return 0, false, nil
}
var version int64
err := db.Table("goose_db_version").
Where("is_applied = ?", true).
Order("version_id DESC").
Select("version_id").
Limit(1).
Row().
Scan(&version)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return int(version), true, nil
}
func loadLegacyDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
if db == nil || !db.Migrator().HasTable("database_schema_versions") {
return 0, false, nil
}
var version int
err := db.Table("database_schema_versions").
Where("id = ?", 1).
Select("version").
Limit(1).
Row().
Scan(&version)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return version, true, nil
}
func bootstrapLegacySchemaVersion(db *gorm.DB, ctx BridgeContext) error {
if err := ctx.AutoMigrateLegacySchemaMetadata(db); err != nil {
return err
}
version, exists, err := loadLegacyDatabaseSchemaVersion(db)
if err != nil {
return err
}
if exists {
if int64(version) > LegacyBridgeVersion {
return fmt.Errorf("legacy schema version %d is newer than supported terminal version %d", version, LegacyBridgeVersion)
}
return nil
}
return ctx.SaveLegacyDatabaseSchemaVersion(db, 7)
}
func upgradeLegacyToTerminal(db *gorm.DB, backend string, ctx BridgeContext) error {
if err := bootstrapLegacySchemaVersion(db, ctx); err != nil {
return err
}
version, exists, err := loadLegacyDatabaseSchemaVersion(db)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("legacy schema version record is missing after bootstrap")
}
return ctx.UpgradeLegacyDatabaseSchema(db, backend, version)
}
func validateGooseBridgeState(db *gorm.DB) error {
version, exists, err := LoadDatabaseVersion(db)
if err != nil {
return err
}
if !exists {
return nil
}
if int64(version) < LegacyBridgeVersion {
return fmt.Errorf("goose schema version %d is below legacy bridge baseline %d", version, LegacyBridgeVersion)
}
if int64(version) > CurrentTargetVersion() {
return fmt.Errorf("goose schema version %d is newer than application target version %d", version, CurrentTargetVersion())
}
return nil
}
func finalizeLegacyToGooseBridge(db *gorm.DB) error {
gooseVersion, exists, err := LoadDatabaseVersion(db)
if err != nil {
return err
}
if !exists || int64(gooseVersion) < LegacyBridgeVersion {
return nil
}
if !db.Migrator().HasTable("database_schema_versions") {
return nil
}
if err := db.Exec("DROP TABLE IF EXISTS database_schema_versions").Error; err != nil {
return fmt.Errorf("drop legacy schema versions table failed: %w", err)
}
slog.Info("completed legacy-to-goose migration bridge", "goose_version", gooseVersion)
return nil
}
func ValidateRegisteredSchema(db *gorm.DB) error {
if err := validateNodeCapabilitiesJSON(db); err != nil {
return err
}
return nil
}
func EnsureDatabaseSchemaUpToDate(db *gorm.DB, backend string, ctx BridgeContext) error {
state, err := detectSchemaState(db, ctx)
if err != nil {
return err
}
switch state {
case schemaMigrationStateFresh:
if err := ctx.InitializeFreshDatabaseSchema(db, backend); err != nil {
return err
}
case schemaMigrationStateLegacyOnly:
if err := upgradeLegacyToTerminal(db, backend, ctx); err != nil {
return err
}
case schemaMigrationStateGooseOnly:
if err := validateGooseBridgeState(db); err != nil {
return err
}
case schemaMigrationStateLegacyBootstrap:
if err := upgradeLegacyToTerminal(db, backend, ctx); err != nil {
return err
}
case schemaMigrationStateMixed:
legacyVersion, exists, err := loadLegacyDatabaseSchemaVersion(db)
if err != nil {
return err
}
if exists && int64(legacyVersion) != LegacyBridgeVersion {
return fmt.Errorf("incomplete mixed migration state: legacy schema version %d does not match bridge terminal version %d", legacyVersion, LegacyBridgeVersion)
}
if err := validateGooseBridgeState(db); err != nil {
return err
}
default:
return fmt.Errorf("unknown schema migration state: %d", state)
}
if err := runMigrations(db, backend, ctx); err != nil {
return err
}
if err := finalizeLegacyToGooseBridge(db); err != nil {
return err
}
if err := ctx.RepairCurrentSchemaState(db, backend); err != nil {
return err
}
if err := ctx.ValidateCurrentDatabaseSchema(db, backend); err != nil {
return err
}
return ValidateRegisteredSchema(db)
}
@@ -0,0 +1,50 @@
package goose
import (
"encoding/json"
"fmt"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/gorm"
)
const versionNodeCapabilitiesJSON int64 = 202606020001
// migration202606020001 adds a future-proof JSON field for node capability
// summaries after the legacy v17 migration bridge.
func migration202606020001(backend string, ctx Context) *presslygoose.Migration {
return newGORMMigration(
versionNodeCapabilitiesJSON,
"202606020001_add_node_capabilities_json.go",
backend,
ctx,
migrateNodeCapabilitiesJSON,
)
}
func migrateNodeCapabilitiesJSON(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
return err
}
emptyJSON, err := json.Marshal([]string{})
if err != nil {
return fmt.Errorf("marshal default node capabilities: %w", err)
}
if err := db.Exec(
`UPDATE nodes SET capabilities_json = ? WHERE capabilities_json IS NULL OR TRIM(capabilities_json) = ''`,
string(emptyJSON),
).Error; err != nil {
return fmt.Errorf("backfill nodes.capabilities_json: %w", err)
}
return validateNodeCapabilitiesJSON(db)
}
func validateNodeCapabilitiesJSON(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasColumn("nodes", "capabilities_json") {
return fmt.Errorf("column nodes.capabilities_json is missing")
}
return nil
}
@@ -0,0 +1,71 @@
package goose
import (
"context"
"database/sql"
"fmt"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/gorm"
)
const LegacyBridgeVersion int64 = 17
type migrationFunc func(ctx Context, db *gorm.DB, backend string) error
func newBaselineMigration() *presslygoose.Migration {
migration := presslygoose.NewGoMigration(LegacyBridgeVersion, nil, nil)
migration.Source = fmt.Sprintf("%05d_legacy_terminal_baseline.go", LegacyBridgeVersion)
return migration
}
func newGORMMigration(version int64, source string, backend string, ctx Context, up migrationFunc) *presslygoose.Migration {
migration := presslygoose.NewGoMigration(version, &presslygoose.GoFunc{
RunDB: func(_ context.Context, sqlDB *sql.DB) error {
gormDB, err := openGORMDB(ctx, sqlDB, backend)
if err != nil {
return err
}
if backend == "postgres" {
return gormDB.Transaction(func(tx *gorm.DB) error {
return up(ctx, tx, backend)
})
}
return up(ctx, gormDB, backend)
},
}, nil)
migration.Source = source
return migration
}
func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration {
return []*presslygoose.Migration{
migration202606020001(backend, ctx),
}
}
func buildMigrations(backend string, ctx Context) []*presslygoose.Migration {
migrations := []*presslygoose.Migration{newBaselineMigration()}
migrations = append(migrations, registeredMigrations(backend, ctx)...)
return migrations
}
func CurrentTargetVersion() int64 {
var maxVersion int64 = LegacyBridgeVersion
for _, migration := range buildMigrations("sqlite", noopContext{}) {
if migration.Version > maxVersion {
maxVersion = migration.Version
}
}
return maxVersion
}
type noopContext struct{}
func (noopContext) ApplyCurrentSchema(db *gorm.DB, backend string) error {
return nil
}
func (noopContext) RegisterSharding(db *gorm.DB, backend string) error {
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package goose
import (
"context"
"database/sql"
"fmt"
"github.com/glebarez/sqlite"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
type Context interface {
ApplyCurrentSchema(db *gorm.DB, backend string) error
RegisterSharding(db *gorm.DB, backend string) error
}
func dialectForBackend(backend string) (presslygoose.Dialect, error) {
switch backend {
case "postgres":
return presslygoose.DialectPostgres, nil
case "sqlite":
return presslygoose.DialectSQLite3, nil
default:
return "", fmt.Errorf("unsupported database backend: %s", backend)
}
}
func openGORMDB(ctx Context, db *sql.DB, backend string) (*gorm.DB, error) {
var dialector gorm.Dialector
switch backend {
case "postgres":
dialector = postgres.New(postgres.Config{Conn: db})
case "sqlite":
dialector = &sqlite.Dialector{Conn: db}
default:
return nil, fmt.Errorf("unsupported database backend: %s", backend)
}
gormDB, err := gorm.Open(dialector, &gorm.Config{
NamingStrategy: schema.NamingStrategy{},
})
if err != nil {
return nil, err
}
if err := ctx.RegisterSharding(gormDB, backend); err != nil {
return nil, err
}
return gormDB, nil
}
func buildProvider(db *gorm.DB, backend string, ctx Context) (*presslygoose.Provider, error) {
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
dialect, err := dialectForBackend(backend)
if err != nil {
return nil, err
}
return presslygoose.NewProvider(
dialect,
sqlDB,
nil,
presslygoose.WithDisableGlobalRegistry(true),
presslygoose.WithGoMigrations(buildMigrations(backend, ctx)...),
)
}
func runMigrations(db *gorm.DB, backend string, ctx Context) error {
provider, err := buildProvider(db, backend, ctx)
if err != nil {
return fmt.Errorf("build goose provider: %w", err)
}
if _, err := provider.Up(context.Background()); err != nil {
return fmt.Errorf("goose up failed: %w", err)
}
return nil
}
+12 -1
View File
@@ -54,12 +54,23 @@ func registeredModels() []any {
}
}
func schemaMetadataModels() []any {
func currentSchemaMetadataModels() []any {
return nil
}
func legacySchemaMetadataModels() []any {
return []any{
&DatabaseSchemaVersion{},
}
}
func schemaMetadataModels() []any {
models := make([]any, 0, len(currentSchemaMetadataModels())+len(legacySchemaMetadataModels()))
models = append(models, currentSchemaMetadataModels()...)
models = append(models, legacySchemaMetadataModels()...)
return models
}
func buildDBModels() ([]dbModel, error) {
models := registeredModels()
result := make([]dbModel, 0, len(models))
+85 -13
View File
@@ -89,6 +89,10 @@ func findDBModelByTableName(t *testing.T, tableName string) dbModel {
return dbModel{}
}
func expectedCurrentDatabaseVersion() int {
return int(currentGooseTargetVersion())
}
func TestIsDatabaseEmpty(t *testing.T) {
db := openTestSQLiteDB(t, "empty.db")
@@ -195,8 +199,8 @@ func TestUpgradeDatabaseSchemaV15ToV16AppliesCompressedReleaseSchema(t *testing.
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
if err := autoMigrateLegacySchemaMetadata(db); err != nil {
t.Fatalf("auto migrate legacy schema metadata: %v", err)
}
if err := applyCurrentSchema(db, "sqlite"); err != nil {
t.Fatalf("apply current schema: %v", err)
@@ -303,8 +307,17 @@ func TestEnsureDatabaseSchemaUpToDateInitializesFreshDatabase(t *testing.T) {
if !exists {
t.Fatal("expected database schema version to be recorded")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected fresh database to avoid legacy database_schema_versions table")
}
if !db.Migrator().HasTable("goose_db_version") {
t.Fatal("expected fresh database to initialize goose_db_version")
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected fresh database to apply goose migration nodes.capabilities_json")
}
}
@@ -337,8 +350,17 @@ func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) {
if !exists {
t.Fatal("expected legacy database to gain a schema version record")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected legacy database_schema_versions table to be removed after bridging to goose")
}
if !db.Migrator().HasTable("goose_db_version") {
t.Fatal("expected legacy upgrade to initialize goose_db_version")
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected legacy upgrade to apply goose migration nodes.capabilities_json")
}
}
@@ -402,8 +424,8 @@ func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteDomainCertificateFields(t *te
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
if err := autoMigrateLegacySchemaMetadata(db); err != nil {
t.Fatalf("auto migrate legacy schema metadata: %v", err)
}
for _, item := range registeredModels() {
@@ -478,7 +500,7 @@ func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *t
fromVersion: legacyDatabaseSchemaVersion,
toVersion: 11,
migrate: func(tx *gorm.DB, backend string) error {
return autoMigrateSchemaMetadata(tx)
return autoMigrateLegacySchemaMetadata(tx)
},
validate: func(tx *gorm.DB, backend string) error {
return gorm.ErrInvalidDB
@@ -532,8 +554,11 @@ func TestEnsureDatabaseSchemaUpToDateAddsNodeIPManualOverride(t *testing.T) {
if !exists {
t.Fatal("expected schema version record to exist")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected migration chain to include nodes.capabilities_json")
}
}
@@ -616,8 +641,11 @@ func TestEnsureDatabaseSchemaUpToDateV16BackfillsNodeColumnsWhenNewColumnsAlread
if !exists {
t.Fatal("expected schema version record to exist")
}
if version != currentDatabaseSchemaVersion {
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected v16 upgrade path to apply goose migration nodes.capabilities_json")
}
}
@@ -655,6 +683,50 @@ func TestEnsureDatabaseSchemaUpToDateV16DropsLegacyNodeColumnsWhenAlreadyCurrent
t.Fatalf("expected current-schema cleanup to drop legacy nodes.%s column", column)
}
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected current-schema legacy version table to be removed after goose bridge")
}
if !db.Migrator().HasTable("goose_db_version") {
t.Fatal("expected current-schema goose_db_version table to exist")
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected current-schema repair to preserve goose column nodes.capabilities_json")
}
}
func TestEnsureDatabaseSchemaUpToDateKeepsGooseOnlyDatabaseOnReentry(t *testing.T) {
db := openBareTestSQLiteDB(t, "goose-only-reentry.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("first ensureDatabaseSchemaUpToDate: %v", err)
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected first initialization to avoid legacy table")
}
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("second ensureDatabaseSchemaUpToDate: %v", err)
}
if db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
t.Fatal("expected goose-only database to remain free of legacy version table")
}
version, exists, err := loadGooseDatabaseVersion(db)
if err != nil {
t.Fatalf("loadGooseDatabaseVersion: %v", err)
}
if !exists {
t.Fatal("expected goose-only database to keep goose version record")
}
if version != expectedCurrentDatabaseVersion() {
t.Fatalf("unexpected goose version: got %d want %d", version, expectedCurrentDatabaseVersion())
}
if !db.Migrator().HasColumn(&Node{}, "capabilities_json") {
t.Fatal("expected goose-only database to keep nodes.capabilities_json")
}
}
func TestAllRegisteredMigrationsHaveValidationDefined(t *testing.T) {
+44 -40
View File
@@ -93,8 +93,17 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
}
}
func autoMigrateSchemaMetadata(db *gorm.DB) error {
for _, item := range schemaMetadataModels() {
func autoMigrateCurrentSchemaMetadata(db *gorm.DB) error {
for _, item := range currentSchemaMetadataModels() {
if err := db.AutoMigrate(item); err != nil {
return err
}
}
return nil
}
func autoMigrateLegacySchemaMetadata(db *gorm.DB) error {
for _, item := range legacySchemaMetadataModels() {
if err := db.AutoMigrate(item); err != nil {
return err
}
@@ -245,7 +254,7 @@ func applyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...str
}
}
slog.Info("applyCurrentSchema: step 1/5 - auto migrate schema metadata")
if err := autoMigrateSchemaMetadata(db); err != nil {
if err := autoMigrateCurrentSchemaMetadata(db); err != nil {
return err
}
slog.Info("applyCurrentSchema: step 2/5 - migrate proxy route https column")
@@ -268,7 +277,7 @@ func applyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...str
return nil
}
func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
func loadLegacyDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
if db == nil {
return 0, false, nil
}
@@ -286,19 +295,37 @@ func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
return state.Version, true, nil
}
func saveDatabaseSchemaVersion(db *gorm.DB, version int) error {
func saveLegacyDatabaseSchemaVersion(db *gorm.DB, version int) error {
if err := autoMigrateLegacySchemaMetadata(db); err != nil {
return err
}
return db.Save(&DatabaseSchemaVersion{
ID: databaseSchemaVersionRowID,
Version: version,
}).Error
}
func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
version, exists, err := loadGooseDatabaseVersion(db)
if err != nil {
return 0, false, err
}
if exists {
return version, true, nil
}
return loadLegacyDatabaseSchemaVersion(db)
}
func saveDatabaseSchemaVersion(db *gorm.DB, version int) error {
return saveLegacyDatabaseSchemaVersion(db, version)
}
func validateDatabaseSchemaV2(db *gorm.DB, backend string) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
return fmt.Errorf("table %s is missing", (&DatabaseSchemaVersion{}).TableName())
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) && !db.Migrator().HasTable("goose_db_version") {
return fmt.Errorf("neither %s nor goose_db_version exists", (&DatabaseSchemaVersion{}).TableName())
}
models, err := buildDBModels()
if err != nil {
@@ -1385,7 +1412,7 @@ func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseS
if err := migration.validate(db, backend); err != nil {
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
}
if err := saveDatabaseSchemaVersion(db, migration.toVersion); err != nil {
if err := saveLegacyDatabaseSchemaVersion(db, migration.toVersion); err != nil {
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
}
return nil
@@ -1398,26 +1425,26 @@ func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseS
if err := migration.validate(tx, backend); err != nil {
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
}
if err := saveDatabaseSchemaVersion(tx, migration.toVersion); err != nil {
if err := saveLegacyDatabaseSchemaVersion(tx, migration.toVersion); err != nil {
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
}
return nil
})
}
func upgradeDatabaseSchema(db *gorm.DB, backend string, version int) error {
if version > currentDatabaseSchemaVersion {
return fmt.Errorf("database schema version %d is newer than application version %d", version, currentDatabaseSchemaVersion)
func upgradeLegacyDatabaseSchema(db *gorm.DB, backend string, version int) error {
if version > legacyMigrationTerminalVersion {
return fmt.Errorf("database schema version %d is newer than legacy migration terminal version %d", version, legacyMigrationTerminalVersion)
}
if version < legacyDatabaseSchemaVersion {
slog.Warn("database schema version is below supported baseline; treating it as historical initial schema", "version", version, "baseline", legacyDatabaseSchemaVersion)
version = legacyDatabaseSchemaVersion
}
if version == currentDatabaseSchemaVersion {
if version == legacyMigrationTerminalVersion {
return nil
}
migrationMap := databaseSchemaMigrationMap()
for version < currentDatabaseSchemaVersion {
for version < legacyMigrationTerminalVersion {
migration, ok := migrationMap[version]
if !ok {
return fmt.Errorf("database schema migration from v%d is not defined", version)
@@ -1458,32 +1485,9 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
if err := ensureDefaultWAFRuleGroup(db); err != nil {
return err
}
if err := validateCurrentDatabaseSchema(db, backend); err != nil {
return err
}
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
return nil
}
func ensureDatabaseSchemaUpToDate(db *gorm.DB, backend string) error {
version, exists, err := loadDatabaseSchemaVersion(db)
if err != nil {
return err
}
if exists {
if err := upgradeDatabaseSchema(db, backend, version); err != nil {
return err
}
return dropLegacyNodeColumns(db, backend)
}
empty, err := isDatabaseEmpty(db)
if err != nil {
return err
}
if empty {
return initializeFreshDatabaseSchema(db, backend)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
return err
}
return upgradeDatabaseSchema(db, backend, legacyDatabaseSchemaVersion)
func upgradeDatabaseSchema(db *gorm.DB, backend string, version int) error {
return upgradeLegacyDatabaseSchema(db, backend, version)
}
+1
View File
@@ -37,6 +37,7 @@ type Node struct {
RelayAgentAccessAddr string `json:"relay_agent_access_addr" gorm:"size:255"`
RelayClientAccessAddr string `json:"relay_client_access_addr" gorm:"size:255"`
RelayClientProxyURL string `json:"relay_client_proxy_url" gorm:"size:512"`
CapabilitiesJSON string `json:"capabilities_json" gorm:"type:text;not null;default:'[]'"`
RelayStatus string `json:"relay_status" gorm:"size:16;not null;default:'unknown'"`
RelayWebServerEnabled bool `json:"relay_web_server_enabled" gorm:"not null;default:false"`
}
+52
View File
@@ -52,6 +52,17 @@ func InitOptionMap() {
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
common.OptionMap["GeoIPProvider"] = common.GeoIPProvider
common.OptionMap["DatabaseAutoCleanupEnabled"] = strconv.FormatBool(common.DatabaseAutoCleanupEnabled)
common.OptionMap["UptimeKumaEnabled"] = strconv.FormatBool(common.UptimeKumaEnabled)
common.OptionMap["UptimeKumaUrl"] = common.UptimeKumaUrl
common.OptionMap["UptimeKumaUsername"] = common.UptimeKumaUsername
common.OptionMap["UptimeKumaPassword"] = common.UptimeKumaPassword
common.OptionMap["UptimeKumaMonitorScope"] = common.UptimeKumaMonitorScope
common.OptionMap["UptimeKumaSelectedSites"] = common.UptimeKumaSelectedSites
common.OptionMap["UptimeKumaSyncInterval"] = strconv.Itoa(common.UptimeKumaSyncInterval)
common.OptionMap["UptimeKumaInterval"] = strconv.Itoa(common.UptimeKumaInterval)
common.OptionMap["UptimeKumaRetry"] = strconv.Itoa(common.UptimeKumaRetry)
common.OptionMap["UptimeKumaRetryInterval"] = strconv.Itoa(common.UptimeKumaRetryInterval)
common.OptionMap["UptimeKumaTimeout"] = strconv.Itoa(common.UptimeKumaTimeout)
common.OptionMap["DatabaseAutoCleanupRetentionDays"] = strconv.Itoa(common.DatabaseAutoCleanupRetentionDays)
common.OptionMap["OpenRestyDefaultServerReturnStatus"] = strconv.Itoa(common.OpenRestyDefaultServerReturnStatus)
common.OptionMap["OpenRestyWorkerProcesses"] = common.OpenRestyWorkerProcesses
@@ -70,6 +81,7 @@ func InitOptionMap() {
common.OptionMap["OpenRestyProxySendTimeout"] = strconv.Itoa(common.OpenRestyProxySendTimeout)
common.OptionMap["OpenRestyProxyReadTimeout"] = strconv.Itoa(common.OpenRestyProxyReadTimeout)
common.OptionMap["OpenRestyWebsocketEnabled"] = strconv.FormatBool(common.OpenRestyWebsocketEnabled)
common.OptionMap["OpenRestyHTTP3Enabled"] = strconv.FormatBool(common.OpenRestyHTTP3Enabled)
common.OptionMap["OpenRestyProxyRequestBufferingEnabled"] = strconv.FormatBool(common.OpenRestyProxyRequestBufferingEnabled)
common.OptionMap["OpenRestyProxyBufferingEnabled"] = strconv.FormatBool(common.OpenRestyProxyBufferingEnabled)
common.OptionMap["OpenRestyProxyBuffers"] = common.OpenRestyProxyBuffers
@@ -115,6 +127,9 @@ func UpdateOptions(options []Option) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
for _, item := range options {
if item.Key == "UptimeKumaPassword" && strings.TrimSpace(item.Value) == "" {
continue
}
option := Option{
Key: item.Key,
}
@@ -132,6 +147,9 @@ func UpdateOptions(options []Option) error {
}
for _, item := range options {
if item.Key == "UptimeKumaPassword" && strings.TrimSpace(item.Value) == "" {
continue
}
updateOptionMap(item.Key, item.Value)
}
return nil
@@ -208,6 +226,38 @@ func updateOptionMap(key string, value string) {
common.GeoIPProvider = value
shouldRefreshGeoIP = true
}
case "UptimeKumaEnabled":
common.UptimeKumaEnabled = value == "true"
case "UptimeKumaUrl":
common.UptimeKumaUrl = value
case "UptimeKumaUsername":
common.UptimeKumaUsername = value
case "UptimeKumaPassword":
common.UptimeKumaPassword = value
case "UptimeKumaMonitorScope":
common.UptimeKumaMonitorScope = value
case "UptimeKumaSelectedSites":
common.UptimeKumaSelectedSites = value
case "UptimeKumaSyncInterval":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaSyncInterval = v
}
case "UptimeKumaInterval":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaInterval = v
}
case "UptimeKumaRetry":
if v, err := strconv.Atoi(value); err == nil && v >= 0 {
common.UptimeKumaRetry = v
}
case "UptimeKumaRetryInterval":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaRetryInterval = v
}
case "UptimeKumaTimeout":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UptimeKumaTimeout = v
}
case "DatabaseAutoCleanupEnabled":
common.DatabaseAutoCleanupEnabled = value == "true"
case "DatabaseAutoCleanupRetentionDays":
@@ -278,6 +328,8 @@ func updateOptionMap(key string, value string) {
}
case "OpenRestyWebsocketEnabled":
common.OpenRestyWebsocketEnabled = value == "true"
case "OpenRestyHTTP3Enabled":
common.OpenRestyHTTP3Enabled = value == "true"
case "OpenRestyProxyRequestBufferingEnabled":
common.OpenRestyProxyRequestBufferingEnabled = value == "true"
case "OpenRestyProxyBufferingEnabled":
@@ -0,0 +1,60 @@
package model
import (
schemagoose "openflare/model/goose"
"gorm.io/gorm"
)
func currentGooseTargetVersion() int64 {
return schemagoose.CurrentTargetVersion()
}
func loadGooseDatabaseVersion(db *gorm.DB) (int, bool, error) {
return schemagoose.LoadDatabaseVersion(db)
}
func ensureDatabaseSchemaUpToDate(db *gorm.DB, backend string) error {
return schemagoose.EnsureDatabaseSchemaUpToDate(db, backend, databaseSchemaMigrationContext{})
}
func (databaseSchemaMigrationContext) RegisterSharding(db *gorm.DB, backend string) error {
return registerSharding(db, backend)
}
func (databaseSchemaMigrationContext) AutoMigrateLegacySchemaMetadata(db *gorm.DB) error {
return autoMigrateLegacySchemaMetadata(db)
}
func (databaseSchemaMigrationContext) InitializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
return initializeFreshDatabaseSchema(db, backend)
}
func (databaseSchemaMigrationContext) IsDatabaseEmpty(db *gorm.DB) (bool, error) {
return isDatabaseEmpty(db)
}
func (databaseSchemaMigrationContext) RepairCurrentSchemaState(db *gorm.DB, backend string) error {
if err := dropLegacyNodeColumns(db, backend); err != nil {
return err
}
if err := ensureDefaultGitHubAuthSource(db); err != nil {
return err
}
if err := ensureDefaultWAFRuleGroup(db); err != nil {
return err
}
return nil
}
func (databaseSchemaMigrationContext) SaveLegacyDatabaseSchemaVersion(db *gorm.DB, version int) error {
return saveLegacyDatabaseSchemaVersion(db, version)
}
func (databaseSchemaMigrationContext) UpgradeLegacyDatabaseSchema(db *gorm.DB, backend string, version int) error {
return upgradeLegacyDatabaseSchema(db, backend, version)
}
func (databaseSchemaMigrationContext) ValidateCurrentDatabaseSchema(db *gorm.DB, backend string) error {
return validateCurrentDatabaseSchema(db, backend)
}
+5
View File
@@ -67,6 +67,11 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
}
uptimekumaRoute := apiRouter.Group("/uptimekuma")
uptimekumaRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
uptimekumaRoute.POST("/sync", controller.SyncUptimeKuma)
}
authSourceRoute := apiRouter.Group("/auth-sources")
authSourceRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
@@ -0,0 +1,454 @@
package router_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"openflare/common"
"openflare/model"
"openflare/router"
"strings"
"sync"
"testing"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
// mockKumaServer simulates Uptime Kuma's Engine.IO/Socket.IO polling endpoints
type mockKumaServer struct {
mu sync.Mutex
postsReceived []string
pendingPackets chan string
monitorList string // JSON representing map[string]UptimeKumaMonitor
}
func newMockKumaServer(monitorList string) *mockKumaServer {
return &mockKumaServer{
pendingPackets: make(chan string, 100),
monitorList: monitorList,
}
}
func (s *mockKumaServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
transport := r.URL.Query().Get("transport")
sid := r.URL.Query().Get("sid")
if r.Method == "GET" {
if transport == "polling" && sid == "" {
// Handshake response
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
_, _ = w.Write([]byte(`0{"sid":"mock-sid"}`))
return
}
if transport == "polling" && sid == "mock-sid" {
// Long-polling GET request
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
select {
case pkt := <-s.pendingPackets:
_, _ = w.Write([]byte(pkt))
case <-time.After(100 * time.Millisecond):
_, _ = w.Write([]byte(""))
}
return
}
} else if r.Method == "POST" {
bodyBytes, _ := io.ReadAll(r.Body)
bodyStr := string(bodyBytes)
s.postsReceived = append(s.postsReceived, bodyStr)
w.Header().Set("Content-Type", "text/plain;charset=UTF-8")
w.WriteHeader(http.StatusOK)
if bodyStr == "40" {
// Namespace Connect event
// Immediately queue the monitorList payload to be fetched by the next GET poll
s.pendingPackets <- fmt.Sprintf(`42["monitorList",%s]`, s.monitorList)
return
}
if strings.HasPrefix(bodyStr, "42") {
// Socket.IO message: 42<ackID>[...]
payload := bodyStr[2:]
// Find ack ID (digits at the start of payload)
digitsEnd := 0
for digitsEnd < len(payload) && payload[digitsEnd] >= '0' && payload[digitsEnd] <= '9' {
digitsEnd++
}
if digitsEnd == 0 {
return
}
ackIDStr := payload[:digitsEnd]
jsonArrayStr := payload[digitsEnd:]
var arr []json.RawMessage
if err := json.Unmarshal([]byte(jsonArrayStr), &arr); err != nil || len(arr) == 0 {
return
}
var eventName string
_ = json.Unmarshal(arr[0], &eventName)
switch eventName {
case "login", "loginByToken":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
case "getTags":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tags\":[{\"id\":10,\"name\":\"OpenFlare\",\"color\":\"#4f46e5\"}]}]", ackIDStr)
case "addTag":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tag\":{\"id\":10}}]", ackIDStr)
case "add":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"monitorID\":100}]", ackIDStr)
case "addMonitorTag":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
case "editMonitor":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
case "deleteMonitor":
s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr)
}
}
}
}
func TestUptimeKumaSyncDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
// Keep integration disabled
common.UptimeKumaEnabled = false
// Request sync, should fail
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Success {
t.Fatal("expected sync request to fail when integration is disabled")
}
if !strings.Contains(resp.Message, "disabled") {
t.Fatalf("expected error message to mention integration is disabled, got: %s", resp.Message)
}
}
func TestUptimeKumaSyncSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
// Clean up route table just in case
_ = model.DB.Where("1 = 1").Delete(&model.ProxyRoute{}).Error
// Seed proxy routes
// Route 1: site-a (exists in Uptime Kuma but has different check parameters - should trigger editMonitor)
routeA := &model.ProxyRoute{
SiteName: "site-a",
Domain: "site-a.com",
Domains: `["site-a.com"]`,
OriginURL: "http://10.0.0.1",
Enabled: true,
EnableHTTPS: false,
}
// Route 2: site-b (does not exist in Uptime Kuma - should trigger add & addMonitorTag)
routeB := &model.ProxyRoute{
SiteName: "site-b",
Domain: "site-b.com",
Domains: `["site-b.com"]`,
OriginURL: "https://10.0.0.2",
Enabled: true,
EnableHTTPS: true,
}
// Route 3: site-c (disabled locally - should NOT be processed/created)
routeC := &model.ProxyRoute{
SiteName: "site-c",
Domain: "site-c.com",
Domains: `["site-c.com"]`,
OriginURL: "http://10.0.0.3",
Enabled: false,
EnableHTTPS: false,
}
if err := model.DB.Create(routeA).Error; err != nil {
t.Fatalf("failed to seed routeA: %v", err)
}
if err := model.DB.Create(routeB).Error; err != nil {
t.Fatalf("failed to seed routeB: %v", err)
}
if err := model.DB.Create(routeC).Error; err != nil {
t.Fatalf("failed to seed routeC: %v", err)
}
// Prepare mock monitorList
// 1. "site-old": tagged with OpenFlare but doesn't exist locally anymore -> should trigger deleteMonitor
// 2. "site-a": matches routeA but has interval = 30 (default UptimeKumaInterval is 60) -> should trigger editMonitor
monitorListJSON := `{
"99": {
"id": 99,
"name": "site-old",
"url": "http://site-old.com",
"interval": 60,
"tags": [{"tag_id": 10, "name": "OpenFlare"}]
},
"98": {
"id": 98,
"name": "site-a",
"url": "http://site-a.com",
"interval": 30,
"tags": [{"tag_id": 10, "name": "OpenFlare"}]
}
}`
mockSrv := newMockKumaServer(monitorListJSON)
server := httptest.NewServer(mockSrv)
defer server.Close()
// Backup and set configs
oldEnabled := common.UptimeKumaEnabled
oldUrl := common.UptimeKumaUrl
oldUsername := common.UptimeKumaUsername
oldPassword := common.UptimeKumaPassword
oldScope := common.UptimeKumaMonitorScope
oldInterval := common.UptimeKumaInterval
oldRetry := common.UptimeKumaRetry
oldRetryInterval := common.UptimeKumaRetryInterval
oldTimeout := common.UptimeKumaTimeout
common.UptimeKumaEnabled = true
common.UptimeKumaUrl = server.URL
common.UptimeKumaUsername = "admin"
common.UptimeKumaPassword = "password"
common.UptimeKumaMonitorScope = "all"
common.UptimeKumaInterval = 60
common.UptimeKumaRetry = 0
common.UptimeKumaRetryInterval = 60
common.UptimeKumaTimeout = 48
defer func() {
common.UptimeKumaEnabled = oldEnabled
common.UptimeKumaUrl = oldUrl
common.UptimeKumaUsername = oldUsername
common.UptimeKumaPassword = oldPassword
common.UptimeKumaMonitorScope = oldScope
common.UptimeKumaInterval = oldInterval
common.UptimeKumaRetry = oldRetry
common.UptimeKumaRetryInterval = oldRetryInterval
common.UptimeKumaTimeout = oldTimeout
}()
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. Body: %s", recorder.Code, recorder.Body.String())
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Success {
t.Fatalf("sync request failed: %s", resp.Message)
}
mockSrv.mu.Lock()
posts := mockSrv.postsReceived
mockSrv.mu.Unlock()
// Verify events received
hasLogin := false
hasGetTags := false
hasAddSiteB := false
hasTagSiteB := false
hasEditSiteA := false
hasDeleteOld := false
for _, body := range posts {
if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) {
hasLogin = true
}
if strings.Contains(body, `"getTags"`) {
hasGetTags = true
}
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) && strings.Contains(body, `"https://site-b.com"`) {
hasAddSiteB = true
}
if strings.Contains(body, `"addMonitorTag"`) && strings.Contains(body, `10`) && strings.Contains(body, `100`) {
hasTagSiteB = true
}
if strings.Contains(body, `"editMonitor"`) && strings.Contains(body, `98`) && strings.Contains(body, `"site-a"`) && strings.Contains(body, `"interval":60`) {
hasEditSiteA = true
}
if strings.Contains(body, `"deleteMonitor"`) && strings.Contains(body, `99`) {
hasDeleteOld = true
}
}
if !hasLogin {
t.Error("expected login event to be called")
}
if !hasGetTags {
t.Error("expected getTags event to be called")
}
if !hasAddSiteB {
t.Error("expected site-b to be added")
}
if !hasTagSiteB {
t.Error("expected site-b to be tagged")
}
if !hasEditSiteA {
t.Error("expected site-a to be edited/updated")
}
if !hasDeleteOld {
t.Error("expected site-old to be deleted")
}
}
func TestUptimeKumaSyncSelectedScope(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
// Clean up route table
_ = model.DB.Where("1 = 1").Delete(&model.ProxyRoute{}).Error
// Seed proxy routes
// Route 1: site-a (enabled, in selected list)
routeA := &model.ProxyRoute{
SiteName: "site-a",
Domain: "site-a.com",
Domains: `["site-a.com"]`,
OriginURL: "http://10.0.0.1",
Enabled: true,
EnableHTTPS: false,
}
// Route 2: site-b (enabled, NOT in selected list)
routeB := &model.ProxyRoute{
SiteName: "site-b",
Domain: "site-b.com",
Domains: `["site-b.com"]`,
OriginURL: "http://10.0.0.2",
Enabled: true,
EnableHTTPS: false,
}
if err := model.DB.Create(routeA).Error; err != nil {
t.Fatalf("failed to seed routeA: %v", err)
}
if err := model.DB.Create(routeB).Error; err != nil {
t.Fatalf("failed to seed routeB: %v", err)
}
mockSrv := newMockKumaServer(`{}`)
server := httptest.NewServer(mockSrv)
defer server.Close()
// Backup and set configs
oldEnabled := common.UptimeKumaEnabled
oldUrl := common.UptimeKumaUrl
oldUsername := common.UptimeKumaUsername
oldPassword := common.UptimeKumaPassword
oldScope := common.UptimeKumaMonitorScope
oldSelected := common.UptimeKumaSelectedSites
common.UptimeKumaEnabled = true
common.UptimeKumaUrl = server.URL
common.UptimeKumaUsername = "admin"
common.UptimeKumaPassword = "password"
common.UptimeKumaMonitorScope = "selected"
common.UptimeKumaSelectedSites = "site-a" // site-b is excluded
defer func() {
common.UptimeKumaEnabled = oldEnabled
common.UptimeKumaUrl = oldUrl
common.UptimeKumaUsername = oldUsername
common.UptimeKumaPassword = oldPassword
common.UptimeKumaMonitorScope = oldScope
common.UptimeKumaSelectedSites = oldSelected
}()
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil)
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
var resp apiResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Success {
t.Fatalf("sync request failed: %s", resp.Message)
}
mockSrv.mu.Lock()
posts := mockSrv.postsReceived
mockSrv.mu.Unlock()
hasLogin := false
hasAddSiteA := false
hasAddSiteB := false
for _, body := range posts {
if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) {
hasLogin = true
}
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-a"`) {
hasAddSiteA = true
}
if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) {
hasAddSiteB = true
}
}
if !hasLogin {
t.Error("expected login event to be called")
}
if !hasAddSiteA {
t.Error("expected site-a to be added")
}
if hasAddSiteB {
t.Error("expected site-b NOT to be added (not in selected scope)")
}
}
@@ -152,6 +152,7 @@ type openRestyConfigSnapshot struct {
ProxySendTimeout int `json:"proxy_send_timeout"`
ProxyReadTimeout int `json:"proxy_read_timeout"`
WebsocketEnabled bool `json:"websocket_enabled"`
HTTP3Enabled bool `json:"http3_enabled"`
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
ProxyBuffers string `json:"proxy_buffers"`
@@ -952,6 +953,7 @@ func buildOpenRestyConfigSnapshot() openRestyConfigSnapshot {
ProxySendTimeout: common.OpenRestyProxySendTimeout,
ProxyReadTimeout: common.OpenRestyProxyReadTimeout,
WebsocketEnabled: common.OpenRestyWebsocketEnabled,
HTTP3Enabled: common.OpenRestyHTTP3Enabled,
ProxyRequestBuffering: common.OpenRestyProxyRequestBufferingEnabled,
ProxyBufferingEnabled: common.OpenRestyProxyBufferingEnabled,
ProxyBuffers: common.OpenRestyProxyBuffers,
@@ -1071,6 +1073,7 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
appendIfChanged("OpenRestyProxySendTimeout", fmt.Sprintf("%d", left.ProxySendTimeout), fmt.Sprintf("%d", right.ProxySendTimeout))
appendIfChanged("OpenRestyProxyReadTimeout", fmt.Sprintf("%d", left.ProxyReadTimeout), fmt.Sprintf("%d", right.ProxyReadTimeout))
appendIfChanged("OpenRestyWebsocketEnabled", fmt.Sprintf("%t", left.WebsocketEnabled), fmt.Sprintf("%t", right.WebsocketEnabled))
appendIfChanged("OpenRestyHTTP3Enabled", fmt.Sprintf("%t", left.HTTP3Enabled), fmt.Sprintf("%t", right.HTTP3Enabled))
appendIfChanged("OpenRestyProxyRequestBufferingEnabled", fmt.Sprintf("%t", left.ProxyRequestBuffering), fmt.Sprintf("%t", right.ProxyRequestBuffering))
appendIfChanged("OpenRestyProxyBufferingEnabled", fmt.Sprintf("%t", left.ProxyBufferingEnabled), fmt.Sprintf("%t", right.ProxyBufferingEnabled))
appendIfChanged("OpenRestyProxyBuffers", left.ProxyBuffers, right.ProxyBuffers)
@@ -1119,6 +1122,7 @@ func openRestyOptionKeys() []string {
"OpenRestyProxySendTimeout",
"OpenRestyProxyReadTimeout",
"OpenRestyWebsocketEnabled",
"OpenRestyHTTP3Enabled",
"OpenRestyProxyRequestBufferingEnabled",
"OpenRestyProxyBufferingEnabled",
"OpenRestyProxyBuffers",
@@ -489,6 +489,9 @@ func TestPublishConfigVersionRendersMultipleCertificatesForMultiDomainWebsite(t
func TestPublishConfigVersionSkipsHTTPSForDomainsWithoutCertificate(t *testing.T) {
setupServiceTestDB(t)
if err := model.UpdateOption("OpenRestyHTTP3Enabled", "false"); err != nil {
t.Fatalf("UpdateOption failed: %v", err)
}
appCertPEM, appKeyPEM := generateCertificatePair(t, []string{"app.example.com"})
appCertificate, err := CreateTLSCertificate(TLSCertificateInput{
@@ -1338,6 +1341,80 @@ func TestOpenRestyProxyRequestBufferingDefaultsToOff(t *testing.T) {
}
}
func TestPreviewConfigVersionSupportsHTTP3(t *testing.T) {
setupServiceTestDB(t)
appCertPEM, appKeyPEM := generateCertificatePair(t, []string{"h3.example.com"})
appCertificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "h3-cert",
CertPEM: appCertPEM,
KeyPEM: appKeyPEM,
})
if err != nil {
t.Fatalf("CreateTLSCertificate failed: %v", err)
}
_, err = CreateProxyRoute(ProxyRouteInput{
SiteName: "h3-site",
Domains: []string{"h3.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
EnableHTTPS: true,
CertID: &appCertificate.ID,
DomainCertIDs: []uint{appCertificate.ID},
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if err := model.UpdateOption("OpenRestyHTTP3Enabled", "true"); err != nil {
t.Fatalf("UpdateOption OpenRestyHTTP3Enabled failed: %v", err)
}
preview, err := PreviewConfigVersion()
if err != nil {
t.Fatalf("PreviewConfigVersion failed: %v", err)
}
if !strings.Contains(preview.MainConfig, "listen 443 ssl default_server;\n listen 443 quic reuseport default_server;") {
t.Fatalf("expected default server block to contain quic reuseport listener, main config: %s", preview.MainConfig)
}
if !strings.Contains(preview.RenderedConfig, "listen 443 quic;") {
t.Fatalf("expected routing server block to contain listen 443 quic, rendered config: %s", preview.RenderedConfig)
}
if !strings.Contains(preview.RenderedConfig, "add_header Alt-Svc 'h3=\":443\"; ma=86400';") {
t.Fatalf("expected routing server block to contain Alt-Svc header, rendered config: %s", preview.RenderedConfig)
}
if !strings.Contains(preview.RenderedConfig, "listen 443 ssl;") {
t.Fatal("expected standard listen 443 ssl to be preserved")
}
if !strings.Contains(preview.RenderedConfig, "http2 on;") {
t.Fatal("expected standard http2 on to be preserved")
}
if err := model.UpdateOption("OpenRestyHTTP3Enabled", "false"); err != nil {
t.Fatalf("UpdateOption OpenRestyHTTP3Enabled failed: %v", err)
}
previewOff, err := PreviewConfigVersion()
if err != nil {
t.Fatalf("PreviewConfigVersion failed: %v", err)
}
if strings.Contains(previewOff.MainConfig, "listen 443 quic reuseport default_server;") {
t.Fatal("expected default server block to omit quic listener when disabled")
}
if strings.Contains(previewOff.RenderedConfig, "listen 443 quic;") {
t.Fatal("expected routing server block to omit quic listener when disabled")
}
if strings.Contains(previewOff.RenderedConfig, "add_header Alt-Svc") {
t.Fatal("expected routing server block to omit Alt-Svc header when disabled")
}
}
func setupServiceTestDB(t *testing.T) {
t.Helper()
nodeAccessTokenCache.reset()
+304
View File
@@ -0,0 +1,304 @@
package service
import (
"fmt"
"log/slog"
"openflare/common"
"openflare/model"
"openflare/utils/uptimekuma"
"strings"
"sync/atomic"
"time"
)
var isSyncing atomic.Bool
func SyncToUptimeKuma() error {
if !common.UptimeKumaEnabled {
return fmt.Errorf("Uptime Kuma integration is disabled")
}
if !isSyncing.CompareAndSwap(false, true) {
return fmt.Errorf("sync task is already in progress, please try again later")
}
defer isSyncing.Store(false)
kumaUrl := strings.TrimSpace(common.UptimeKumaUrl)
kumaUsername := strings.TrimSpace(common.UptimeKumaUsername)
kumaPassword := strings.TrimSpace(common.UptimeKumaPassword)
if kumaUrl == "" || kumaUsername == "" || kumaPassword == "" {
return fmt.Errorf("Uptime Kuma URL, username, or password is not configured (URL: %q, Username: %q, PasswordLength: %d)", kumaUrl, kumaUsername, len(kumaPassword))
}
slog.Info("Starting Uptime Kuma sync process", "url", kumaUrl, "username", kumaUsername, "scope", common.UptimeKumaMonitorScope)
// 1. Fetch expected sites
allRoutes, err := model.ListProxyRoutes()
if err != nil {
return fmt.Errorf("failed to list local proxy routes: %w", err)
}
var expectedRoutes []*model.ProxyRoute
scope := common.UptimeKumaMonitorScope
if scope == "selected" {
selectedList := strings.Split(common.UptimeKumaSelectedSites, ",")
selectedMap := make(map[string]bool)
for _, name := range selectedList {
trimmedName := strings.TrimSpace(name)
if trimmedName != "" {
selectedMap[trimmedName] = true
}
}
for _, route := range allRoutes {
if route.Enabled && selectedMap[route.SiteName] {
expectedRoutes = append(expectedRoutes, route)
}
}
} else {
for _, route := range allRoutes {
if route.Enabled {
expectedRoutes = append(expectedRoutes, route)
}
}
}
// 2. Connect to Uptime Kuma
slog.Debug("Connecting to Uptime Kuma socket endpoint", "url", kumaUrl)
client := uptimekuma.NewSocketIOClient(kumaUrl)
if err := client.Connect(); err != nil {
slog.Error("Failed to connect to Uptime Kuma endpoint", "url", kumaUrl, "error", err)
return fmt.Errorf("failed to connect to Uptime Kuma: %w", err)
}
defer client.Close()
// 3. Login
slog.Debug("Sending login request to Uptime Kuma", "username", kumaUsername)
var loginAck string
loginPayload := map[string]string{
"username": kumaUsername,
"password": kumaPassword,
}
loginAck, err = client.Emit("login", loginPayload)
if err != nil {
slog.Error("Failed to send login request to Uptime Kuma", "username", kumaUsername, "error", err)
return fmt.Errorf("login request failed: %w", err)
}
var loginResult struct {
Ok bool `json:"ok"`
}
if err := uptimekuma.ParseAckResponse(loginAck, &loginResult); err != nil || !loginResult.Ok {
slog.Error("Uptime Kuma login verification failed", "username", kumaUsername, "error", err)
return fmt.Errorf("login failed: %w", err)
}
slog.Debug("Successfully logged into Uptime Kuma", "username", kumaUsername)
// 4. Wait for monitor list event
slog.Debug("Waiting for monitor list push from Uptime Kuma")
select {
case <-client.GetMonitorListChan():
slog.Debug("Received monitor list from Uptime Kuma")
case <-time.After(5 * time.Second):
slog.Error("Timeout waiting for Uptime Kuma monitorList push event")
return fmt.Errorf("timeout waiting for monitorList event from Uptime Kuma")
}
// 5. Get existing tags to find "OpenFlare"
slog.Debug("Fetching tags from Uptime Kuma")
tagsAck, err := client.Emit("getTags")
if err != nil {
slog.Error("Failed to request tags from Uptime Kuma", "error", err)
return fmt.Errorf("failed to fetch tags: %w", err)
}
var tagsResult struct {
Ok bool `json:"ok"`
Tags []uptimekuma.UptimeKumaTagItem `json:"tags"`
}
if err := uptimekuma.ParseAckResponse(tagsAck, &tagsResult); err != nil {
slog.Error("Failed to parse tags response from Uptime Kuma", "error", err)
return fmt.Errorf("parse tags response failed: %w", err)
}
var openFlareTagID int
for _, t := range tagsResult.Tags {
if t.Name == "OpenFlare" {
openFlareTagID = t.ID
break
}
}
// Create "OpenFlare" tag if not exists
if openFlareTagID == 0 {
slog.Debug("OpenFlare tag not found, creating new tag")
addTagAck, err := client.Emit("addTag", map[string]string{
"name": "OpenFlare",
"color": "#4f46e5",
})
if err != nil {
slog.Error("Failed to create OpenFlare tag in Uptime Kuma", "error", err)
return fmt.Errorf("failed to create tag: %w", err)
}
var tagResult struct {
Ok bool `json:"ok"`
Tag struct {
ID int `json:"id"`
} `json:"tag"`
}
if err := uptimekuma.ParseAckResponse(addTagAck, &tagResult); err != nil || tagResult.Tag.ID == 0 {
slog.Error("Failed to parse addTag response from Uptime Kuma", "error", err)
return fmt.Errorf("parse addTag response failed: %w", err)
}
openFlareTagID = tagResult.Tag.ID
slog.Debug("Successfully created OpenFlare tag", "tag_id", openFlareTagID)
} else {
slog.Debug("Found existing OpenFlare tag", "tag_id", openFlareTagID)
}
// 6. Filter existing monitors by "OpenFlare" tag
existingOpenFlareMonitors := make(map[string]uptimekuma.UptimeKumaMonitor)
monitors := client.GetMonitorList()
for _, m := range monitors {
hasOpenFlareTag := false
for _, tag := range m.Tags {
if tag.Name == "OpenFlare" || tag.ID == openFlareTagID {
hasOpenFlareTag = true
break
}
}
if hasOpenFlareTag {
existingOpenFlareMonitors[m.Name] = m
}
}
// Helper to format route URL
getRouteURL := func(route *model.ProxyRoute) string {
domains, err := decodeStoredDomains(route.Domains, route.Domain)
domain := route.Domain
if err == nil && len(domains) > 0 {
domain = domains[0]
}
if route.EnableHTTPS {
return "https://" + domain
}
return "http://" + domain
}
expectedSitesMap := make(map[string]bool)
// 7. Sync Loop
for _, route := range expectedRoutes {
expectedSitesMap[route.SiteName] = true
targetURL := getRouteURL(route)
existing, exists := existingOpenFlareMonitors[route.SiteName]
if !exists {
// Create monitor
slog.Info("Creating monitor in Uptime Kuma", "name", route.SiteName, "url", targetURL)
monitorPayload := map[string]any{
"type": "http",
"name": route.SiteName,
"url": targetURL,
"interval": common.UptimeKumaInterval,
"maxretries": common.UptimeKumaRetry,
"retryInterval": common.UptimeKumaRetryInterval,
"timeout": common.UptimeKumaTimeout,
"active": true,
"resendInterval": 0,
"expiryNotification": false,
"ignoreTls": false,
"accepted_statuscodes": []string{"200-299"},
"dns_resolve_type": "A",
"conditions": []any{},
}
addAck, err := client.Emit("add", monitorPayload)
if err != nil {
slog.Error("Failed to add monitor to Uptime Kuma", "name", route.SiteName, "error", err)
continue
}
var addResult struct {
Ok bool `json:"ok"`
MonitorID int `json:"monitorID"`
}
if err := uptimekuma.ParseAckResponse(addAck, &addResult); err != nil || addResult.MonitorID == 0 {
slog.Error("Failed to parse add monitor result", "name", route.SiteName, "error", err)
continue
}
// Add tag
slog.Debug("Adding OpenFlare tag to the new monitor", "name", route.SiteName, "monitor_id", addResult.MonitorID, "tag_id", openFlareTagID)
tagAck, err := client.Emit("addMonitorTag", openFlareTagID, addResult.MonitorID, "")
if err != nil {
slog.Error("Failed to add tag to monitor in Uptime Kuma", "name", route.SiteName, "monitorID", addResult.MonitorID, "error", err)
} else {
if err := uptimekuma.ParseAckResponse(tagAck, nil); err != nil {
slog.Error("Failed to parse add tag result", "name", route.SiteName, "monitorID", addResult.MonitorID, "error", err)
} else {
slog.Debug("OpenFlare tag successfully added to monitor", "name", route.SiteName, "monitor_id", addResult.MonitorID)
}
}
} else {
// Check if updates are needed
needsUpdate := existing.Url != targetURL ||
existing.Interval != common.UptimeKumaInterval ||
existing.MaxRetries != common.UptimeKumaRetry ||
existing.RetryInterval != common.UptimeKumaRetryInterval ||
existing.Timeout != common.UptimeKumaTimeout
if needsUpdate {
slog.Info("Updating monitor in Uptime Kuma due to settings mismatch",
"name", route.SiteName,
"url_changed", existing.Url != targetURL,
"interval_changed", existing.Interval != common.UptimeKumaInterval,
"max_retries_changed", existing.MaxRetries != common.UptimeKumaRetry,
"retry_interval_changed", existing.RetryInterval != common.UptimeKumaRetryInterval,
"timeout_changed", existing.Timeout != common.UptimeKumaTimeout,
)
monitorPayload := map[string]any{
"id": existing.ID,
"type": "http",
"name": route.SiteName,
"url": targetURL,
"interval": common.UptimeKumaInterval,
"maxretries": common.UptimeKumaRetry,
"retryInterval": common.UptimeKumaRetryInterval,
"timeout": common.UptimeKumaTimeout,
"active": true,
"resendInterval": 0,
"expiryNotification": false,
"ignoreTls": false,
"accepted_statuscodes": []string{"200-299"},
"dns_resolve_type": "A",
"conditions": []any{},
}
editAck, err := client.Emit("editMonitor", monitorPayload)
if err != nil {
slog.Error("Failed to edit monitor in Uptime Kuma", "name", route.SiteName, "error", err)
} else {
if err := uptimekuma.ParseAckResponse(editAck, nil); err != nil {
slog.Error("Failed to parse edit monitor result", "name", route.SiteName, "error", err)
} else {
slog.Info("Successfully updated monitor in Uptime Kuma", "name", route.SiteName)
}
}
}
}
}
// 8. Delete Loop
for name, m := range existingOpenFlareMonitors {
if !expectedSitesMap[name] {
slog.Info("Deleting monitor in Uptime Kuma", "name", name, "monitorID", m.ID)
deleteAck, err := client.Emit("deleteMonitor", m.ID)
if err != nil {
slog.Error("Failed to delete monitor in Uptime Kuma", "name", name, "monitorID", m.ID, "error", err)
} else {
if err := uptimekuma.ParseAckResponse(deleteAck, nil); err != nil {
slog.Error("Failed to parse delete monitor result", "name", name, "monitorID", m.ID, "error", err)
}
}
}
}
return nil
}
+22 -1
View File
@@ -69,6 +69,21 @@ type wafIPGroupAutoRuleEnv struct {
ClientErrorCount int `expr:"client_error_count"`
ServerErrorCount int `expr:"server_error_count"`
LastSeenUnix int64 `expr:"last_seen_unix"`
statusCounts map[int]int
}
func (env wafIPGroupAutoRuleEnv) StatusCount(code int) int {
if env.statusCounts == nil {
return 0
}
return env.statusCounts[code]
}
func (env wafIPGroupAutoRuleEnv) StatusRatio(code int) float64 {
if env.RequestCount <= 0 || env.statusCounts == nil {
return 0.0
}
return float64(env.statusCounts[code]) / float64(env.RequestCount)
}
type wafIPGroupAutoAccumulator struct {
@@ -79,6 +94,7 @@ type wafIPGroupAutoAccumulator struct {
clientErrorCount int
serverErrorCount int
lastSeen time.Time
statusCounts map[int]int
}
type WAFIPGroupInput struct {
@@ -755,10 +771,14 @@ func evaluateParsedWAFIPGroupAutoConfig(config wafIPGroupAutoConfig, now time.Ti
}
acc := accumulators[ip]
if acc == nil {
acc = &wafIPGroupAutoAccumulator{ip: ip}
acc = &wafIPGroupAutoAccumulator{
ip: ip,
statusCounts: make(map[int]int),
}
accumulators[ip] = acc
}
acc.requestCount++
acc.statusCounts[item.StatusCode]++
if item.StatusCode == http.StatusNotFound {
acc.status404Count++
}
@@ -800,6 +820,7 @@ func (acc *wafIPGroupAutoAccumulator) toExprEnv() wafIPGroupAutoRuleEnv {
IPHostCount: acc.ipHostCount,
ClientErrorCount: acc.clientErrorCount,
ServerErrorCount: acc.serverErrorCount,
statusCounts: acc.statusCounts,
}
if acc.requestCount > 0 {
env.Status404Ratio = float64(acc.status404Count) / float64(acc.requestCount)
+50 -3
View File
@@ -228,7 +228,7 @@ func TestSyncWAFIPGroupAutomaticExprRules(t *testing.T) {
AutoConfig: json.RawMessage(`{
"lookback_minutes": 60,
"rules": [
{"name":"单 IP 404 高频扫描","expr":"request_count > 100 && status_404_ratio >= 0.8"},
{"name":"单 IP 404 高频扫描","expr":"request_count > 100 && StatusRatio(404) >= 0.8"},
{"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"}
]
}`),
@@ -267,7 +267,7 @@ func TestWAFIPGroupAutoConfigReturnsMatchedIPs(t *testing.T) {
AutoConfig: json.RawMessage(`{
"lookback_minutes": 60,
"rules": [
{"name":"单 IP 404 高频扫描","expr":"request_count > 100 && status_404_ratio >= 0.8"},
{"name":"单 IP 404 高频扫描","expr":"request_count > 100 && StatusRatio(404) >= 0.8"},
{"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"}
]
}`),
@@ -383,7 +383,7 @@ func TestWAFIPGroupAutomaticTTLExpiration(t *testing.T) {
"lookback_minutes": 60,
"ttl": 10,
"rules": [
{"name":"404 Scan","expr":"request_count > 100 && status_404_ratio >= 0.8"}
{"name":"404 Scan","expr":"request_count > 100 && StatusRatio(404) >= 0.8"}
]
}`),
})
@@ -464,3 +464,50 @@ func seedWAFNodeAccessLogs(t *testing.T, loggedAt time.Time, remoteAddr string,
}
}
}
func TestSyncWAFIPGroupAutomaticCustomStatusRules(t *testing.T) {
setupServiceTestDB(t)
now := time.Now().UTC()
// Seed 10 requests from 203.0.113.50, where 3 return 403, 7 return 200
seedWAFNodeAccessLogsWithStatus(t, now, "203.0.113.50", "app.example.com", 7, http.StatusOK)
seedWAFNodeAccessLogsWithStatus(t, now, "203.0.113.50", "app.example.com", 3, http.StatusForbidden)
group, err := CreateWAFIPGroup(WAFIPGroupInput{
Name: "custom status code blacklist",
Type: WAFIPGroupTypeAutomatic,
Enabled: true,
AutoConfig: json.RawMessage(`{
"lookback_minutes": 60,
"rules": [
{"name":"高频 403 探测","expr":"StatusCount(403) >= 3 && StatusRatio(403) >= 0.3"}
]
}`),
})
if err != nil {
t.Fatalf("CreateWAFIPGroup failed: %v", err)
}
result, err := SyncWAFIPGroup(group.ID)
if err != nil {
t.Fatalf("SyncWAFIPGroup failed: %v", err)
}
if result.IPCount != 1 || result.Group.IPList[0] != "203.0.113.50" {
t.Fatalf("expected 203.0.113.50 to be matched, got %#v", result)
}
}
func seedWAFNodeAccessLogsWithStatus(t *testing.T, loggedAt time.Time, remoteAddr string, host string, count int, statusCode int) {
t.Helper()
for i := 0; i < count; i++ {
if err := model.DB.Create(&model.NodeAccessLog{
NodeID: "node-waf-auto",
LoggedAt: loggedAt.Add(-time.Duration(i%30) * time.Second),
RemoteAddr: remoteAddr,
Host: host,
Path: "/probe",
StatusCode: statusCode,
}).Error; err != nil {
t.Fatalf("failed to seed access log: %v", err)
}
}
}
+47 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"log/slog"
"sync"
"time"
)
type WSMessage struct {
@@ -65,13 +66,58 @@ type WSHub struct {
name string
mu sync.RWMutex
clients map[string]*WSClient
done chan struct{}
}
func NewWSHub(name string) *WSHub {
return &WSHub{
h := &WSHub{
name: name,
clients: make(map[string]*WSClient),
done: make(chan struct{}),
}
go h.startPingLoop()
return h
}
func (h *WSHub) Close() {
close(h.done)
}
func (h *WSHub) startPingLoop() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-h.done:
return
case <-ticker.C:
h.mu.RLock()
if len(h.clients) == 0 {
h.mu.RUnlock()
continue
}
clients := make([]*WSClient, 0, len(h.clients))
for _, client := range h.clients {
clients = append(clients, client)
}
h.mu.RUnlock()
for _, client := range clients {
if !client.Send(WSMessage{
Type: "ping",
}) {
slog.Warn("ws client send ping failed, queue full, disconnecting", "hub", h.name, "id", client.id)
h.Disconnect(client.id)
}
}
}
}
}
func ShutdownWSHubs() {
DefaultAgentWSHub.Close()
DefaultFlaredWSHub.Close()
DefaultRelayWSHub.Close()
}
func (h *WSHub) Register(id string) *WSClient {
@@ -305,7 +305,7 @@ func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot) string {
"{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
"{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile),
"{{OpenRestyConnectionUpgradeMap}}", renderConnectionUpgradeMap(),
"{{OpenRestyDefaultServerBlock}}", renderDefaultServerBlock(cfg.DefaultServerReturnStatus),
"{{OpenRestyDefaultServerBlock}}", renderDefaultServerBlock(cfg.DefaultServerReturnStatus, cfg.HTTP3Enabled),
"{{OpenRestyAccessLogPath}}", AccessLogPlaceholder,
"{{OpenRestyErrorLogPath}}", ErrorLogPlaceholder,
"{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)),
@@ -379,7 +379,13 @@ func renderHTTPRedirectServer(serverNames string) string {
func renderHTTPSServer(serverNames string, siteName string, originURL string, originHost string, certificateID uint, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID)
keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID)
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, certPath, keyPath, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
var h3Listen string
var h3Header string
if cfg.HTTP3Enabled {
h3Listen = " listen 443 quic;\n"
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
}
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
}
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []CustomHeader, upstreamConfig routeUpstreamConfig, cfg ConfigSnapshot) string {
@@ -586,10 +592,14 @@ func renderConnectionUpgradeMap() string {
return " map $http_upgrade $connection_upgrade {\n default upgrade;\n '' \"\";\n }\n\n"
}
func renderDefaultServerBlock(statusCode int) string {
func renderDefaultServerBlock(statusCode int, http3Enabled bool) string {
if statusCode <= 0 {
statusCode = 421
}
var h3Default string
if http3Enabled {
h3Default = "\n listen 443 quic reuseport default_server;"
}
return strings.Join([]string{
" server {",
" listen 80 default_server;",
@@ -599,7 +609,7 @@ func renderDefaultServerBlock(statusCode int) string {
" }",
"",
" server {",
" listen 443 ssl default_server;",
fmt.Sprintf(" listen 443 ssl default_server;%s", h3Default),
" server_name _;",
"",
" ssl_reject_handshake on;",
@@ -175,6 +175,7 @@ type ConfigSnapshot struct {
ProxySendTimeout int `json:"proxy_send_timeout"`
ProxyReadTimeout int `json:"proxy_read_timeout"`
WebsocketEnabled bool `json:"websocket_enabled"`
HTTP3Enabled bool `json:"http3_enabled"`
ProxyRequestBuffering bool `json:"proxy_request_buffering"`
ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"`
ProxyBuffers string `json:"proxy_buffers"`
+373
View File
@@ -0,0 +1,373 @@
package uptimekuma
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type UptimeKumaMonitor struct {
ID int `json:"id"`
Name string `json:"name"`
Url string `json:"url"`
Type string `json:"type"`
Interval int `json:"interval"`
MaxRetries int `json:"maxretries"`
RetryInterval int `json:"retryInterval"`
Timeout int `json:"timeout"`
Tags []UptimeKumaTag `json:"tags"`
}
type UptimeKumaTag struct {
ID int `json:"tag_id"`
Name string `json:"name"`
Color string `json:"color"`
}
type UptimeKumaTagItem struct {
ID int `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
type SocketIOClient struct {
baseURL string
httpClient *http.Client
sid string
ackMutex sync.Mutex
ackID int
ackChanMap map[int]chan string
doneChan chan struct{}
closeOnce sync.Once
monitorListMutex sync.RWMutex
monitorList map[string]UptimeKumaMonitor
monitorListChan chan struct{}
monitorListOnce sync.Once
ctx context.Context
cancel context.CancelFunc
err error
}
func NewSocketIOClient(baseURL string) *SocketIOClient {
ctx, cancel := context.WithCancel(context.Background())
return &SocketIOClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
ackChanMap: make(map[int]chan string),
doneChan: make(chan struct{}),
monitorListChan: make(chan struct{}),
monitorList: make(map[string]UptimeKumaMonitor),
ctx: ctx,
cancel: cancel,
}
}
func (c *SocketIOClient) Connect() error {
slog.Debug("Uptime Kuma client starting handshake", "baseURL", c.baseURL)
// 1. Handshake
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling", c.baseURL)
reqHandshake, err := http.NewRequestWithContext(c.ctx, "GET", u, nil)
if err != nil {
return fmt.Errorf("create handshake request failed: %w", err)
}
resp, err := c.httpClient.Do(reqHandshake)
if err != nil {
slog.Error("Uptime Kuma handshake connection failed", "url", u, "error", err)
return fmt.Errorf("handshake request failed: %w", err)
}
defer resp.Body.Close()
bs, err := io.ReadAll(resp.Body)
if err != nil {
slog.Error("Failed to read Uptime Kuma handshake response body", "error", err)
return fmt.Errorf("read handshake body failed: %w", err)
}
bodyStr := string(bs)
slog.Debug("Received handshake response from Uptime Kuma", "body", bodyStr)
if len(bodyStr) == 0 || bodyStr[0] != '0' {
return fmt.Errorf("invalid handshake response format: %s", bodyStr)
}
var hs struct {
Sid string `json:"sid"`
}
if err := json.Unmarshal([]byte(bodyStr[1:]), &hs); err != nil {
return fmt.Errorf("unmarshal handshake sid failed: %w", err)
}
c.sid = hs.Sid
slog.Debug("Uptime Kuma handshake success", "sid", c.sid)
// 2. Namespace Connect
slog.Debug("Sending namespace connect request to Uptime Kuma", "sid", c.sid)
connectURL := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
req, err := http.NewRequestWithContext(c.ctx, "POST", connectURL, strings.NewReader("40"))
if err != nil {
return fmt.Errorf("create connect request failed: %w", err)
}
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
respConnect, err := c.httpClient.Do(req)
if err != nil {
slog.Error("Uptime Kuma namespace connect request failed", "sid", c.sid, "error", err)
return fmt.Errorf("namespace connect failed: %w", err)
}
respConnect.Body.Close()
slog.Debug("Namespace connected successfully to Uptime Kuma", "sid", c.sid)
// 3. Start Polling Loop
go c.pollLoop()
return nil
}
func (c *SocketIOClient) pollLoop() {
slog.Debug("Uptime Kuma polling loop started", "sid", c.sid)
defer c.Close()
for {
select {
case <-c.doneChan:
slog.Debug("Uptime Kuma polling loop stopped (doneChan closed)", "sid", c.sid)
return
default:
}
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
reqPoll, err := http.NewRequestWithContext(c.ctx, "GET", u, nil)
if err != nil {
slog.Error("Failed to create Uptime Kuma polling request", "sid", c.sid, "error", err)
c.err = err
return
}
resp, err := c.httpClient.Do(reqPoll)
if err != nil {
slog.Error("Uptime Kuma polling request failed", "sid", c.sid, "error", err)
c.err = err
return
}
bs, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
slog.Error("Failed to read Uptime Kuma polling body", "sid", c.sid, "error", err)
c.err = err
return
}
bodyStr := string(bs)
if len(bodyStr) == 0 {
continue
}
slog.Debug("Received polling payload from Uptime Kuma", "length", len(bodyStr))
packets := strings.Split(bodyStr, "\x1e")
for _, pkt := range packets {
if len(pkt) == 0 {
continue
}
engineIOType := pkt[0]
payload := pkt[1:]
slog.Debug("Parsing engine.io packet", "type", string(engineIOType), "payload_len", len(payload))
switch engineIOType {
case '2': // Ping
slog.Debug("Received engine.io ping, responding with pong", "sid", c.sid)
c.sendPong()
case '4': // Message
if len(payload) == 0 {
continue
}
socketIOType := payload[0]
socketIOPayload := payload[1:]
slog.Debug("Parsing socket.io packet", "type", string(socketIOType), "payload", socketIOPayload)
switch socketIOType {
case '2': // Event
c.handleEvent(socketIOPayload)
case '3': // Ack
c.handleAck(socketIOPayload)
}
}
}
}
}
func (c *SocketIOClient) sendPong() {
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
req, err := http.NewRequestWithContext(c.ctx, "POST", u, strings.NewReader("3"))
if err != nil {
return
}
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
resp, err := c.httpClient.Do(req)
if err == nil {
resp.Body.Close()
}
}
func (c *SocketIOClient) handleEvent(payload string) {
var arr []json.RawMessage
if err := json.Unmarshal([]byte(payload), &arr); err != nil || len(arr) < 2 {
return
}
var eventName string
if err := json.Unmarshal(arr[0], &eventName); err != nil {
return
}
if eventName == "monitorList" {
var list map[string]UptimeKumaMonitor
if err := json.Unmarshal(arr[1], &list); err == nil {
c.monitorListMutex.Lock()
c.monitorList = list
c.monitorListMutex.Unlock()
c.monitorListOnce.Do(func() {
close(c.monitorListChan)
})
}
}
}
func (c *SocketIOClient) handleAck(payload string) {
idx := strings.IndexByte(payload, '[')
if idx == -1 {
return
}
ackIDStr := payload[:idx]
ackID, err := strconv.Atoi(ackIDStr)
if err != nil {
return
}
c.ackMutex.Lock()
ch, ok := c.ackChanMap[ackID]
if ok {
delete(c.ackChanMap, ackID)
c.ackMutex.Unlock()
select {
case ch <- payload[idx:]:
default:
}
} else {
c.ackMutex.Unlock()
}
}
func (c *SocketIOClient) Emit(event string, args ...any) (string, error) {
c.ackMutex.Lock()
id := c.ackID
c.ackID++
ch := make(chan string, 1)
c.ackChanMap[id] = ch
c.ackMutex.Unlock()
payloadArr := []any{event}
payloadArr = append(payloadArr, args...)
bs, err := json.Marshal(payloadArr)
if err != nil {
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
slog.Error("Failed to marshal event payload", "event", event, "error", err)
return "", err
}
body := fmt.Sprintf("42%d%s", id, string(bs))
slog.Debug("Emitting Socket.IO event", "event", event, "ackID", id, "payload", string(bs))
u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid)
req, err := http.NewRequestWithContext(c.ctx, "POST", u, strings.NewReader(body))
if err != nil {
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
return "", err
}
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
resp, err := c.httpClient.Do(req)
if err != nil {
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
slog.Error("Failed to send Emit request", "event", event, "ackID", id, "error", err)
return "", err
}
resp.Body.Close()
select {
case result := <-ch:
slog.Debug("Received Ack for event", "event", event, "ackID", id, "response", result)
return result, nil
case <-time.After(10 * time.Second):
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
slog.Error("Timeout waiting for event Ack", "event", event, "ackID", id)
return "", fmt.Errorf("timeout waiting for ack for event: %s", event)
case <-c.doneChan:
c.ackMutex.Lock()
delete(c.ackChanMap, id)
c.ackMutex.Unlock()
slog.Error("Client closed while waiting for event Ack", "event", event, "ackID", id)
return "", fmt.Errorf("client closed while waiting for event ack: %s", event)
}
}
func (c *SocketIOClient) Close() {
c.closeOnce.Do(func() {
c.cancel()
close(c.doneChan)
})
}
func (c *SocketIOClient) GetMonitorListChan() <-chan struct{} {
return c.monitorListChan
}
func (c *SocketIOClient) GetMonitorList() map[string]UptimeKumaMonitor {
c.monitorListMutex.RLock()
defer c.monitorListMutex.RUnlock()
// Return a copy to prevent concurrent map read/write access
m := make(map[string]UptimeKumaMonitor, len(c.monitorList))
for k, v := range c.monitorList {
m[k] = v
}
return m
}
func ParseAckResponse(response string, target any) error {
var arr []json.RawMessage
if err := json.Unmarshal([]byte(response), &arr); err != nil || len(arr) == 0 {
return fmt.Errorf("invalid ack response format: %s", response)
}
var status struct {
Ok bool `json:"ok"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(arr[0], &status); err == nil {
if !status.Ok {
errMsg := status.Msg
if errMsg == "" {
errMsg = "unknown error from Uptime Kuma"
}
return fmt.Errorf("Uptime Kuma error response: %s", errMsg)
}
}
if target != nil {
return json.Unmarshal(arr[0], target)
}
return nil
}
+222
View File
@@ -0,0 +1,222 @@
package wsclient
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/websocket"
)
type Config struct {
BaseURL string
Token string
Timeout time.Duration
HeaderKey string // e.g. "X-Agent-Token", "X-Tunnel-Token"
WSPath string // e.g. "/api/relay/ws", "/api/agent/ws", "/api/flared/ws"
}
type Client struct {
cfg Config
}
type WSMessage struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload,omitempty"`
}
type MessageHandler interface {
OnConnect(ctx context.Context) error
HandleMessage(ctx context.Context, msg WSMessage) error
OnClose(err error)
}
type Connection struct {
Conn *websocket.Conn
URL string
ReadTimeout time.Duration
}
func New(cfg Config) *Client {
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
cfg.Token = strings.TrimSpace(cfg.Token)
cfg.HeaderKey = strings.TrimSpace(cfg.HeaderKey)
cfg.WSPath = strings.TrimSpace(cfg.WSPath)
return &Client{
cfg: cfg,
}
}
func (c *Client) SetToken(token string) {
c.cfg.Token = strings.TrimSpace(token)
}
func (c *Client) URL() string {
wsURL, err := c.BuildWebsocketURL()
if err != nil {
return ""
}
return wsURL
}
func (c *Client) BuildWebsocketURL() (string, error) {
parsed, err := url.Parse(c.cfg.BaseURL)
if err != nil {
return "", err
}
switch parsed.Scheme {
case "http":
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
case "ws", "wss":
default:
return "", errors.New("server_url scheme must be http, https, ws, or wss")
}
wsPath := c.cfg.WSPath
if !strings.HasPrefix(wsPath, "/") {
wsPath = "/" + wsPath
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + wsPath
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
func (c *Client) Connect(ctx context.Context) (*Connection, error) {
wsURL, err := c.BuildWebsocketURL()
if err != nil {
return nil, err
}
if c.cfg.Token == "" {
return nil, errors.New("ws token is empty")
}
origin := c.cfg.BaseURL
if origin == "" {
origin = "http://localhost"
}
config, err := websocket.NewConfig(wsURL, origin)
if err != nil {
return nil, err
}
config.Header = http.Header{}
if c.cfg.HeaderKey != "" {
config.Header.Set(c.cfg.HeaderKey, c.cfg.Token)
}
if c.cfg.Timeout > 0 {
config.Dialer = &net.Dialer{Timeout: c.cfg.Timeout}
}
slog.Debug("ws dialing server", "url", wsURL)
conn, err := config.DialContext(ctx)
if err != nil {
return nil, err
}
slog.Debug("ws dial succeeded", "url", wsURL)
return &Connection{Conn: conn, URL: wsURL, ReadTimeout: websocketReadTimeout(c.cfg.Timeout)}, nil
}
func (conn *Connection) SendMessage(msgType string, payload any) error {
if conn == nil || conn.Conn == nil {
return errors.New("ws connection is nil")
}
slog.Debug("ws sending message", "type", msgType)
// Create the outbound message wrapper
message := struct {
Type string `json:"type"`
Payload any `json:"payload,omitempty"`
}{
Type: msgType,
Payload: payload,
}
_ = conn.Conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
return websocket.JSON.Send(conn.Conn, message)
}
func (conn *Connection) Receive(target any) error {
if conn == nil || conn.Conn == nil {
return errors.New("ws connection is nil")
}
if conn.ReadTimeout > 0 {
_ = conn.Conn.SetReadDeadline(time.Now().Add(conn.ReadTimeout))
}
err := websocket.JSON.Receive(conn.Conn, target)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
slog.Debug("ws receive timeout waiting for server message", "timeout", conn.ReadTimeout)
}
return err
}
return nil
}
func websocketReadTimeout(requestTimeout time.Duration) time.Duration {
timeout := requestTimeout * 6
if timeout < 75*time.Second {
return 75 * time.Second
}
return timeout
}
func (conn *Connection) RunReceiveLoop(ctx context.Context, handler MessageHandler) error {
doneChan := make(chan struct{})
defer close(doneChan)
go func() {
select {
case <-ctx.Done():
_ = conn.Close()
case <-doneChan:
}
}()
if err := handler.OnConnect(ctx); err != nil {
handler.OnClose(err)
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
var raw WSMessage
if err := conn.Receive(&raw); err != nil {
handler.OnClose(err)
return err
}
switch raw.Type {
case "ping":
slog.Debug("ws received ping from server, replying with pong")
if err := conn.SendMessage("pong", nil); err != nil {
slog.Error("ws send pong response failed", "error", err)
}
case "pong":
slog.Debug("ws received pong response from server")
default:
if err := handler.HandleMessage(ctx, raw); err != nil {
slog.Error("ws handler failed to process message", "type", raw.Type, "error", err)
return err
}
}
}
}
func (conn *Connection) Close() error {
if conn == nil || conn.Conn == nil {
return nil
}
return conn.Conn.Close()
}
+1 -1
View File
@@ -211,7 +211,7 @@ export function buildNodeDockerInstallCommand(
`docker pull ${image}`,
`docker rm -f openflare-agent 2>/dev/null || true`,
`docker run -d --name openflare-agent --restart unless-stopped \\`,
` -p 80:80 -p 443:443 \\`,
` -p 80:80 -p 443:443/tcp -p 443:443/udp \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
` ${image}`,
@@ -48,6 +48,7 @@ const defaultPerformanceFields = {
OpenRestyProxySendTimeout: '60',
OpenRestyProxyReadTimeout: '60',
OpenRestyWebsocketEnabled: true,
OpenRestyHTTP3Enabled: true,
OpenRestyProxyRequestBufferingEnabled: false,
OpenRestyProxyBufferingEnabled: true,
OpenRestyProxyBuffers: '16 16k',
@@ -96,6 +97,7 @@ const performanceFieldTooltips: Record<string, string> = {
proxy_read_timeout: '等待上游返回响应的超时时间,单位秒。',
websocket:
'控制是否为反向代理规则自动注入 WebSocket 升级所需的 HTTP/1.1、Upgrade 和 Connection 头。',
http3: '启用后,OpenResty 将在 443 端口启用 HTTP/3 (QUIC) 协议支持。',
proxy_request_buffering:
'控制请求体是否先在 Nginx 侧缓冲后再转发给上游,上传和流式场景经常会用到。',
proxy_buffering:
@@ -233,6 +235,10 @@ export function PerformancePage() {
optionMap.OpenRestyWebsocketEnabled,
true,
),
OpenRestyHTTP3Enabled: toBoolean(
optionMap.OpenRestyHTTP3Enabled,
false,
),
OpenRestyProxyRequestBufferingEnabled: toBoolean(
optionMap.OpenRestyProxyRequestBufferingEnabled,
false,
@@ -450,6 +456,10 @@ export function PerformancePage() {
'OpenRestyWebsocketEnabled',
String(performanceFields.OpenRestyWebsocketEnabled),
],
[
'OpenRestyHTTP3Enabled',
String(performanceFields.OpenRestyHTTP3Enabled),
],
[
'OpenRestyProxyRequestBufferingEnabled',
String(performanceFields.OpenRestyProxyRequestBufferingEnabled),
@@ -1008,6 +1018,17 @@ export function PerformancePage() {
}))
}
/>
<ToggleField
label="http3"
tooltip={performanceFieldTooltips.http3}
checked={performanceFields.OpenRestyHTTP3Enabled}
onChange={(checked) =>
setPerformanceFields((previous) => ({
...previous,
OpenRestyHTTP3Enabled: checked,
}))
}
/>
<ToggleField
label="proxy_request_buffering"
tooltip={performanceFieldTooltips.proxy_request_buffering}
@@ -126,3 +126,9 @@ export function bindEmail(email: string, code: string) {
export function getAboutContent() {
return apiRequest<string>('/about');
}
export function syncUptimeKuma() {
return apiRequest<void>('/uptimekuma/sync', {
method: 'POST',
});
}
@@ -31,8 +31,10 @@ import {
rotateBootstrapToken,
updateOptions,
updateSelf,
syncUptimeKuma,
} from '@/features/settings/api/settings';
import { AuthSourceModal } from '@/features/settings/components/auth-source-modal';
import { UptimeKumaSiteSelectModal } from './uptimekuma-modal';
import type {
BootstrapTokenPayload,
DatabaseCleanupResult,
@@ -87,6 +89,17 @@ const defaultOperationFields = {
NodeOfflineThreshold: '120000',
AgentUpdateRepo: 'Rain-kl/OpenFlare',
GeoIPProvider: 'ipinfo',
UptimeKumaEnabled: false,
UptimeKumaUrl: '',
UptimeKumaUsername: '',
UptimeKumaPassword: '',
UptimeKumaMonitorScope: 'all',
UptimeKumaSelectedSites: '',
UptimeKumaSyncInterval: '5',
UptimeKumaInterval: '60',
UptimeKumaRetry: '0',
UptimeKumaRetryInterval: '60',
UptimeKumaTimeout: '48',
OpenRestyDefaultServerReturnStatus: '421',
OpenRestyWorkerProcesses: 'auto',
OpenRestyWorkerConnections: '4096',
@@ -255,6 +268,7 @@ export function SettingsPage() {
const [cleanupModalState, setCleanupModalState] =
useState<CleanupModalState | null>(null);
const [cleanupRetentionDays, setCleanupRetentionDays] = useState('');
const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false);
const isRoot = (user?.role ?? 0) >= 100;
@@ -428,6 +442,17 @@ export function SettingsPage() {
GlobalWebRateLimitDuration: optionMap.GlobalWebRateLimitDuration ?? '180',
CriticalRateLimitNum: optionMap.CriticalRateLimitNum ?? '100',
CriticalRateLimitDuration: optionMap.CriticalRateLimitDuration ?? '1200',
UptimeKumaEnabled: toBoolean(optionMap.UptimeKumaEnabled, false),
UptimeKumaUrl: optionMap.UptimeKumaUrl ?? '',
UptimeKumaUsername: optionMap.UptimeKumaUsername ?? '',
UptimeKumaPassword: '',
UptimeKumaMonitorScope: optionMap.UptimeKumaMonitorScope ?? 'all',
UptimeKumaSelectedSites: optionMap.UptimeKumaSelectedSites ?? '',
UptimeKumaSyncInterval: optionMap.UptimeKumaSyncInterval ?? '5',
UptimeKumaInterval: optionMap.UptimeKumaInterval ?? '60',
UptimeKumaRetry: optionMap.UptimeKumaRetry ?? '0',
UptimeKumaRetryInterval: optionMap.UptimeKumaRetryInterval ?? '60',
UptimeKumaTimeout: optionMap.UptimeKumaTimeout ?? '48',
ServerAddress: resolvedServerAddress,
});
@@ -656,6 +681,64 @@ export function SettingsPage() {
});
};
const handleUptimeKumaSave = () => {
void runBusyAction('uptimekuma-save', async () => {
const syncInt = Number.parseInt(operationFields.UptimeKumaSyncInterval, 10);
const interval = Number.parseInt(operationFields.UptimeKumaInterval, 10);
const retry = Number.parseInt(operationFields.UptimeKumaRetry, 10);
const retryInt = Number.parseInt(operationFields.UptimeKumaRetryInterval, 10);
const timeout = Number.parseInt(operationFields.UptimeKumaTimeout, 10);
if (operationFields.UptimeKumaEnabled) {
if (!operationFields.UptimeKumaUrl.trim()) {
throw new Error('请输入 Uptime Kuma 地址。');
}
if (!operationFields.UptimeKumaUsername.trim()) {
throw new Error('请输入 Uptime Kuma 用户名。');
}
}
if (Number.isNaN(syncInt) || syncInt <= 0) {
throw new Error('同步间隔必须为正整数。');
}
if (Number.isNaN(interval) || interval <= 0) {
throw new Error('心跳间隔必须为正整数。');
}
if (Number.isNaN(retry) || retry < 0) {
throw new Error('重试次数必须为非负整数。');
}
if (Number.isNaN(retryInt) || retryInt <= 0) {
throw new Error('心跳重试间隔必须为正整数。');
}
if (Number.isNaN(timeout) || timeout <= 0) {
throw new Error('请求超时必须为正整数。');
}
await saveOptionEntries(
[
['UptimeKumaEnabled', String(operationFields.UptimeKumaEnabled)],
['UptimeKumaUrl', operationFields.UptimeKumaUrl.trim()],
['UptimeKumaUsername', operationFields.UptimeKumaUsername.trim()],
['UptimeKumaPassword', operationFields.UptimeKumaPassword],
['UptimeKumaMonitorScope', operationFields.UptimeKumaMonitorScope],
['UptimeKumaSelectedSites', operationFields.UptimeKumaSelectedSites],
['UptimeKumaSyncInterval', String(syncInt)],
['UptimeKumaInterval', String(interval)],
['UptimeKumaRetry', String(retry)],
['UptimeKumaRetryInterval', String(retryInt)],
['UptimeKumaTimeout', String(timeout)],
],
'Uptime Kuma 设置已保存。',
);
});
};
const handleUptimeKumaSync = () => {
void runBusyAction('uptimekuma-sync', async () => {
await syncUptimeKuma();
setFeedback({ tone: 'success', message: '同步任务已成功执行!' });
});
};
const renderTabContent = () => {
if (profileQuery.isLoading || publicStatusQuery.isLoading) {
return <LoadingState />;
@@ -1307,6 +1390,198 @@ export function SettingsPage() {
</div>
)}
</AppCard>
<AppCard
title="Uptime Kuma 集成"
description="通过 API 将 OpenFlare 反代站点同步至 Uptime Kuma 监控。"
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={handleUptimeKumaSync}
disabled={busyKey === 'uptimekuma-sync' || !operationFields.UptimeKumaEnabled}
>
{busyKey === 'uptimekuma-sync' ? '同步中...' : '立即同步'}
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleUptimeKumaSave}
disabled={busyKey === 'uptimekuma-save'}
>
{busyKey === 'uptimekuma-save' ? '保存中...' : '保存设置'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<ToggleField
label="开启 Uptime Kuma"
description="启用后会自动差分同步反代规则站点到 Uptime Kuma 实例。"
checked={operationFields.UptimeKumaEnabled}
onChange={(checked) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaEnabled: checked,
}))
}
/>
{operationFields.UptimeKumaEnabled ? (
<div className="space-y-5 border-t border-[var(--border-default)] pt-5">
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="Uptime Kuma 地址" hint="例如 http://192.168.1.100:3001">
<ResourceInput
value={operationFields.UptimeKumaUrl}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaUrl: event.target.value,
}))
}
placeholder="http://localhost:3001"
/>
</ResourceField>
<ResourceField label="Uptime Kuma 用户名">
<ResourceInput
value={operationFields.UptimeKumaUsername}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaUsername: event.target.value,
}))
}
placeholder="请输入用户名"
/>
</ResourceField>
</div>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField
label="Uptime Kuma 密码"
hint="安全原因不显示历史密码,留空表示不更新。"
>
<ResourceInput
type="password"
value={operationFields.UptimeKumaPassword}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaPassword: event.target.value,
}))
}
placeholder="请输入密码(留空表示不更新)"
/>
</ResourceField>
<ResourceField label="同步间隔 (分钟)">
<ResourceInput
type="number"
value={operationFields.UptimeKumaSyncInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaSyncInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="监控范围">
<ResourceSelect
value={operationFields.UptimeKumaMonitorScope}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaMonitorScope: event.target.value,
}))
}
>
<option value="all">全部站点</option>
<option value="selected">选择站点</option>
</ResourceSelect>
</ResourceField>
</div>
{operationFields.UptimeKumaMonitorScope === 'selected' ? (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-[var(--foreground-primary)]">已选站点</span>
<SecondaryButton type="button" onClick={() => setUptimeKumaModalOpen(true)}>
选择监控站点
</SecondaryButton>
</div>
<div className="text-xs text-[var(--foreground-secondary)] break-all max-h-[80px] overflow-y-auto leading-5">
{operationFields.UptimeKumaSelectedSites
? operationFields.UptimeKumaSelectedSites.split(',').join(', ')
: '未选择任何站点,同步不会执行。'}
</div>
</div>
) : null}
<div className="border-t border-[var(--border-default)] pt-5 space-y-4">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
Uptime Kuma 属性配置
</p>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="心跳间隔 (检测频率秒)" hint="服务检测频率,单位:秒">
<ResourceInput
type="number"
value={operationFields.UptimeKumaInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="重试次数" hint="服务被标记为故障前的最大重试次数">
<ResourceInput
type="number"
value={operationFields.UptimeKumaRetry}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaRetry: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="心跳重试间隔 (秒)" hint="重试间隔时间,单位:秒">
<ResourceInput
type="number"
value={operationFields.UptimeKumaRetryInterval}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaRetryInterval: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="请求超时 (秒)" hint="超时断开时间,单位:秒">
<ResourceInput
type="number"
value={operationFields.UptimeKumaTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaTimeout: event.target.value,
}))
}
/>
</ResourceField>
</div>
</div>
</div>
) : null}
</div>
</AppCard>
<AppCard title="版本与构建信息">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -2050,6 +2325,22 @@ export function SettingsPage() {
}}
/>
<UptimeKumaSiteSelectModal
isOpen={uptimeKumaModalOpen}
selectedSites={
operationFields.UptimeKumaSelectedSites
? operationFields.UptimeKumaSelectedSites.split(',')
: []
}
onClose={() => setUptimeKumaModalOpen(false)}
onSave={(sites) =>
setOperationFields((previous) => ({
...previous,
UptimeKumaSelectedSites: sites.join(','),
}))
}
/>
<AppModal
isOpen={cleanupModalState !== null}
title={`清理${cleanupModalState?.label ?? ''}`}
@@ -0,0 +1,211 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '加载站点列表失败。';
}
export function UptimeKumaSiteSelectModal({
isOpen,
selectedSites,
onClose,
onSave,
}: {
isOpen: boolean;
selectedSites: string[];
onClose: () => void;
onSave: (sites: string[]) => void;
}) {
const [searchTerm, setSearchTerm] = useState('');
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
const { data: routes = [], isLoading, error } = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
enabled: isOpen,
});
useEffect(() => {
if (isOpen) {
setTempSelected(new Set(selectedSites.map(s => s.trim()).filter(Boolean)));
setSearchTerm('');
}
}, [isOpen, selectedSites]);
const toggleSite = (siteName: string) => {
setTempSelected((prev) => {
const next = new Set(prev);
if (next.has(siteName)) {
next.delete(siteName);
} else {
next.add(siteName);
}
return next;
});
};
const handleSelectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.add(route.site_name);
});
return next;
});
};
const handleDeselectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.delete(route.site_name);
});
return next;
});
};
const handleSave = () => {
onSave(Array.from(tempSelected));
onClose();
};
const filteredRoutes = routes.filter(
(route) =>
route.site_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
route.primary_domain.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<AppModal
isOpen={isOpen}
title="选择监控站点"
description="请选择要同步到 Uptime Kuma 监控的站点,支持按站点名称和域名搜索。"
size="lg"
onClose={onClose}
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton type="button" onClick={handleSave}>
保存选择
</PrimaryButton>
</div>
}
>
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex-1">
<ResourceField label="搜索站点">
<ResourceInput
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="按名称或域名搜索..."
/>
</ResourceField>
</div>
<div className="flex gap-2 sm:mt-6">
<SecondaryButton type="button" onClick={handleSelectAll}>
全选过滤项
</SecondaryButton>
<SecondaryButton type="button" onClick={handleDeselectAll}>
清空过滤项
</SecondaryButton>
</div>
</div>
{isLoading ? <LoadingState /> : null}
{error ? (
<ErrorState
title="站点加载失败"
description={getErrorMessage(error)}
/>
) : null}
{!isLoading && !error && routes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
暂无可用的代理站点。
</div>
) : null}
{!isLoading && !error && routes.length > 0 ? (
<div className="max-h-[350px] overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)]">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
<tr>
<th className="w-12 px-4 py-3">选择</th>
<th className="px-4 py-3 font-medium">站点名称</th>
<th className="px-4 py-3 font-medium">主域名</th>
<th className="px-4 py-3 font-medium">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{filteredRoutes.map((route) => {
const isChecked = tempSelected.has(route.site_name);
return (
<tr
key={route.id}
onClick={() => toggleSite(route.site_name)}
className="cursor-pointer hover:bg-[var(--surface-elevated)]"
>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleSite(route.site_name)}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</td>
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
{route.site_name}
</td>
<td className="px-4 py-3 text-[var(--foreground-secondary)]">
{route.primary_domain}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
route.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{route.enabled ? '启用' : '禁用'}
</span>
</td>
</tr>
);
})}
{filteredRoutes.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-[var(--foreground-secondary)]">
无匹配的站点
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
<div className="text-xs text-[var(--foreground-muted)] text-right">
已选择 {tempSelected.size} 个监控站点
</div>
</div>
</AppModal>
);
}
@@ -75,7 +75,7 @@ const typeLabels: Record<WAFIPGroupType, string> = {
const automaticPresetRules = [
{
name: '单 IP 404 高频扫描',
expr: 'request_count > 100 && status_404_ratio >= 0.8',
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
name: '单 IP 直连访问异常',
@@ -587,7 +587,7 @@ export function WAFIPGroupsPage() {
</ResourceField>
<ResourceField
label="自动配置 JSON"
hint="可用字段:request_count、status_404_count、status_404_ratio、ip_host_count、ip_host_ratio。支持 ttl(秒,默认 -1 永久拉黑)。"
hint="可用字段:request_count、status_404_count、status_404_ratio、ip_host_count、ip_host_ratio。方法:StatusCount(code)、StatusRatio(code)。支持 ttl(秒,默认 -1 永久拉黑)。"
>
<ResourceTextarea
value={draft.auto_config_text}
@@ -200,7 +200,7 @@ describe('WAF IP groups', () => {
const textarea = screen.getByLabelText(/自动配置 JSON/);
const value = (textarea as HTMLTextAreaElement).value;
expect(value).toContain('request_count > 100 && status_404_ratio >= 0.8');
expect(value).toContain('request_count > 100 && StatusRatio(404) >= 0.8');
expect(value).toContain('ip_host_count > 50 && ip_host_ratio > 0.5');
});
@@ -260,7 +260,7 @@ describe('WAF IP groups', () => {
lookback_minutes: 60,
rules: [
expect.objectContaining({
expr: 'request_count > 100 && status_404_ratio >= 0.8',
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
}),
],
}),
+7 -2
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
ARG VERSION=dev
FROM golang:1.25-alpine AS builder
@@ -8,11 +9,14 @@ WORKDIR /build
COPY openflared/go.mod openflared/go.sum ./
COPY openflare_server/go.mod openflare_server/go.sum ../openflare_server/
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY openflare_server/ ../openflare_server/
COPY openflared/ .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-flared/internal/config.Version=$VERSION'" -o flared ./cmd/flared
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'openflare-flared/internal/config.Version=$VERSION'" -o flared ./cmd/flared
# Final runtime image
FROM fatedier/frpc:v0.69.0
@@ -27,3 +31,4 @@ ENV OPENFLARE_FRPC_PATH=/usr/bin/frpc
ENTRYPOINT ["/app/flared"]
CMD []
+13 -10
View File
@@ -1,13 +1,10 @@
module openflare-flared
go 1.25.0
go 1.25.7
replace openflare => ../openflare_server
require (
golang.org/x/net v0.55.0
openflare v0.0.0-00010101000000-000000000000
)
require openflare v0.0.0-00010101000000-000000000000
require (
github.com/bwmarrin/snowflake v0.3.0 // indirect
@@ -35,7 +32,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -44,18 +41,24 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pressly/goose/v3 v3.27.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
@@ -65,8 +68,8 @@ require (
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.25.10 // indirect
gorm.io/sharding v0.6.2 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+48 -15
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
@@ -55,8 +57,8 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@@ -64,16 +66,18 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -98,6 +102,8 @@ github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUK
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -106,6 +112,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
@@ -119,11 +127,14 @@ github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -139,6 +150,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
@@ -183,13 +196,33 @@ gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+23 -22
View File
@@ -31,6 +31,7 @@ func (r *Runner) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
r.FrpcManager.Stop()
return ctx.Err()
default:
}
@@ -49,31 +50,31 @@ func (r *Runner) Run(ctx context.Context) error {
}
}
func (r *Runner) handleConnection(ctx context.Context, conn *wsclient.Connection) {
for {
select {
case <-ctx.Done():
return
default:
}
type flaredWSHandler struct {
runner *Runner
}
msg, err := conn.Receive()
if err != nil {
slog.Error("flared ws receive failed", "error", err)
return
}
func (h *flaredWSHandler) OnConnect(ctx context.Context) error {
return nil
}
switch msg.Type {
case "ping":
_ = conn.SendPong()
case "active_config":
// Server notifies there is a new config available
slog.Info("received config update notification from server")
r.SyncService.Trigger()
default:
slog.Debug("ignored unknown ws message type", "type", msg.Type)
}
func (h *flaredWSHandler) HandleMessage(ctx context.Context, msg wsclient.WSMessage) error {
switch msg.Type {
case "active_config":
slog.Info("received config update notification from server")
h.runner.SyncService.Trigger()
default:
slog.Debug("ignored unknown ws message type", "type", msg.Type)
}
return nil
}
func (h *flaredWSHandler) OnClose(err error) {
slog.Error("flared ws receive failed", "error", err)
}
func (r *Runner) handleConnection(ctx context.Context, conn *wsclient.Connection) {
_ = conn.RunReceiveLoop(ctx, &flaredWSHandler{runner: r})
}
func (r *Runner) sleepContext(ctx context.Context, d time.Duration) {
+88 -9
View File
@@ -129,6 +129,8 @@ func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTun
if _, ok := activeRelays[relayID]; !ok {
slog.Info("stopping obsolete frpc process", "relay_id", relayID)
proc.Cancel()
pidPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.pid", relayID))
_ = os.Remove(pidPath)
delete(m.processes, relayID)
}
}
@@ -142,11 +144,16 @@ func (m *Manager) UpdateConfig(ctx context.Context, newConfig *service.FlaredTun
}
func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath string) {
pidPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.pid", relayID))
if proc, ok := m.processes[relayID]; ok {
proc.Cancel()
_ = os.Remove(pidPath)
}
procCtx, cancel := context.WithCancel(context.Background())
if ctx == nil {
ctx = context.Background()
}
procCtx, cancel := context.WithCancel(ctx)
proc := &Process{
RelayID: relayID,
Cancel: cancel,
@@ -156,33 +163,83 @@ func (m *Manager) restartProcess(ctx context.Context, relayID string, configPath
m.processes[relayID] = proc
go func() {
backoff := 1 * time.Second
const maxBackoff = 60 * time.Second
for {
select {
case <-procCtx.Done():
m.mu.Lock()
if procCtx.Err() != nil {
m.mu.Unlock()
return
default:
}
m.mu.Unlock()
ensureNoOrphanProcess(pidPath)
cmd := exec.CommandContext(procCtx, m.cfg.FrpcPath, "-c", configPath)
m.mu.Lock()
proc.Cmd = cmd
proc.Status = "running"
m.mu.Unlock()
startedAt := time.Now()
err := cmd.Start()
if err == nil {
_ = os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0o644)
err = cmd.Wait()
}
_ = os.Remove(pidPath)
m.mu.Lock()
if procCtx.Err() != nil {
proc.Status = "stopped"
m.mu.Unlock()
return
}
err := cmd.Run()
if err != nil {
if procCtx.Err() != nil {
return
}
proc.LastError = err.Error()
proc.Status = "error"
slog.Error("frpc process exited unexpectedly", "relay_id", relayID, "error", err)
time.Sleep(5 * time.Second) // backoff
} else {
proc.Status = "stopped"
proc.LastError = "exited unexpectedly with code 0"
slog.Warn("frpc process exited unexpectedly with code 0", "relay_id", relayID)
}
m.mu.Unlock()
if time.Since(startedAt) >= 10*time.Second {
backoff = 1 * time.Second
}
select {
case <-procCtx.Done():
return
case <-time.After(backoff):
backoff = backoff * 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}()
}
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
for relayID, proc := range m.processes {
if proc != nil && proc.Cancel != nil {
proc.Cancel()
}
pidPath := filepath.Join(m.cfg.DataDir, fmt.Sprintf("frpc_%s.pid", relayID))
_ = os.Remove(pidPath)
delete(m.processes, relayID)
}
}
func buildFrpcToml(relay service.FlaredRelayInfo, proxies []service.FlaredProxyEntry) string {
var buf bytes.Buffer
@@ -269,3 +326,25 @@ func (m *Manager) LoadState() error {
m.mu.Unlock()
return nil
}
func ensureNoOrphanProcess(pidPath string) {
data, err := os.ReadFile(pidPath)
if err != nil {
return
}
var pid int
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
return
}
if pid <= 0 {
return
}
process, err := os.FindProcess(pid)
if err == nil && process != nil {
slog.Warn("attempting to kill potentially orphan process", "pid", pid, "pid_path", pidPath)
_ = process.Kill()
// Wait a little bit to ensure the OS has reclaimed ports
time.Sleep(500 * time.Millisecond)
}
_ = os.Remove(pidPath)
}
+387
View File
@@ -0,0 +1,387 @@
package frpc
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"openflare-flared/internal/config"
"openflare/service"
)
// Helper to write control file for the dummy script
func writeControl(t *testing.T, dir string, exitCode int, delaySeconds int) {
controlPath := filepath.Join(dir, "control.txt")
content := fmt.Sprintf("%d %d\n", exitCode, delaySeconds)
err := os.WriteFile(controlPath, []byte(content), 0644)
if err != nil {
t.Fatalf("failed to write control file: %v", err)
}
}
// Setup a dummy executable script that reads control.txt to decide exit code and sleep duration
func setupDummyScript(t *testing.T) (string, string) {
dir := t.TempDir()
scriptPath := filepath.Join(dir, "dummy_frpc")
// On macOS/Linux, we write a shell script
scriptContent := fmt.Sprintf(`#!/bin/sh
control_file="%s/control.txt"
EXIT_CODE=0
DELAY=0
if [ -f "$control_file" ]; then
read -r EXIT_CODE DELAY < "$control_file"
fi
if [ -n "$DELAY" ] && [ "$DELAY" -gt 0 ] 2>/dev/null; then
sleep "$DELAY"
fi
exit "${EXIT_CODE:-0}"
`, dir)
err := os.WriteFile(scriptPath, []byte(scriptContent), 0755)
if err != nil {
t.Fatalf("failed to write dummy script: %v", err)
}
return scriptPath, dir
}
// Helper to poll for status to eliminate timing flakiness in tests
func assertStatusEventually(t *testing.T, m *Manager, relayID string, expectedStatus string, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
m.mu.RLock()
proc, ok := m.processes[relayID]
m.mu.RUnlock()
if ok && proc.Status == expectedStatus {
return
}
time.Sleep(50 * time.Millisecond)
}
m.mu.RLock()
proc, ok := m.processes[relayID]
var got string
var errStr string
if ok {
got = proc.Status
errStr = proc.LastError
} else {
got = "not_found"
}
m.mu.RUnlock()
t.Fatalf("expected status eventually %s, got %s (err: %s)", expectedStatus, got, errStr)
}
func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
t.Helper()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-time.After(timeout):
t.Fatalf("expected process pid=%d to exit within %s", cmd.Process.Pid, timeout)
case <-done:
}
}
func TestStartProcessSuccess(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 5) // exit code 0, sleep 5s
cfg := &config.Config{
ServerURL: "http://localhost:8080",
TunnelToken: "test-token",
FrpcPath: scriptPath,
DataDir: dir,
StatePath: filepath.Join(dir, "flared-state.json"),
}
m := NewManager(cfg)
newConfig := &service.FlaredTunnelConfigResponse{
Version: "1",
Checksum: "sum1",
Relays: []service.FlaredRelayInfo{
{
RelayNodeID: "relay-1",
Address: "127.0.0.1:7000",
AuthToken: "auth-1",
},
},
Proxies: nil,
}
err := m.UpdateConfig(context.Background(), newConfig)
if err != nil {
t.Fatalf("failed to UpdateConfig: %v", err)
}
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
m.mu.RLock()
proc := m.processes["relay-1"]
m.mu.RUnlock()
proc.Cancel()
assertStatusEventually(t, m, "relay-1", "stopped", 4*time.Second) // wait for clean stop
}
func TestStartProcessFailureAndBackoff(t *testing.T) {
dir := t.TempDir()
invalidScriptPath := filepath.Join(dir, "non_existent_frpc")
cfg := &config.Config{
ServerURL: "http://localhost:8080",
TunnelToken: "test-token",
FrpcPath: invalidScriptPath,
DataDir: dir,
StatePath: filepath.Join(dir, "flared-state.json"),
}
m := NewManager(cfg)
newConfig := &service.FlaredTunnelConfigResponse{
Version: "1",
Checksum: "sum1",
Relays: []service.FlaredRelayInfo{
{
RelayNodeID: "relay-1",
Address: "127.0.0.1:7000",
AuthToken: "auth-1",
},
},
Proxies: nil,
}
_ = m.UpdateConfig(context.Background(), newConfig)
assertStatusEventually(t, m, "relay-1", "error", 4*time.Second)
// Correct the path to dummy script
scriptPath, _ := setupDummyScript(t)
writeControl(t, filepath.Dir(scriptPath), 0, 5)
m.mu.Lock()
m.cfg.FrpcPath = scriptPath
m.mu.Unlock()
// Wait for backoff retry (1s backoff)
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
m.mu.RLock()
proc := m.processes["relay-1"]
m.mu.RUnlock()
proc.Cancel()
}
func TestUnexpectedExit0CPUProtection(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
// Start with immediate exit code 0
writeControl(t, dir, 0, 0)
cfg := &config.Config{
ServerURL: "http://localhost:8080",
TunnelToken: "test-token",
FrpcPath: scriptPath,
DataDir: dir,
StatePath: filepath.Join(dir, "flared-state.json"),
}
m := NewManager(cfg)
newConfig := &service.FlaredTunnelConfigResponse{
Version: "1",
Checksum: "sum1",
Relays: []service.FlaredRelayInfo{
{
RelayNodeID: "relay-1",
Address: "127.0.0.1:7000",
AuthToken: "auth-1",
},
},
Proxies: nil,
}
_ = m.UpdateConfig(context.Background(), newConfig)
assertStatusEventually(t, m, "relay-1", "stopped", 4*time.Second)
m.mu.RLock()
proc := m.processes["relay-1"]
if !strings.Contains(proc.LastError, "exited unexpectedly with code 0") {
t.Errorf("expected LastError to record exit status 0 warning, got %s", proc.LastError)
}
m.mu.RUnlock()
proc.Cancel()
}
func TestBackoffReset(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
// Rapid exit code 1 to increase backoff
writeControl(t, dir, 1, 0)
cfg := &config.Config{
ServerURL: "http://localhost:8080",
TunnelToken: "test-token",
FrpcPath: scriptPath,
DataDir: dir,
StatePath: filepath.Join(dir, "flared-state.json"),
}
m := NewManager(cfg)
newConfig := &service.FlaredTunnelConfigResponse{
Version: "1",
Checksum: "sum1",
Relays: []service.FlaredRelayInfo{
{
RelayNodeID: "relay-1",
Address: "127.0.0.1:7000",
AuthToken: "auth-1",
},
},
Proxies: nil,
}
_ = m.UpdateConfig(context.Background(), newConfig)
// Wait to crash
assertStatusEventually(t, m, "relay-1", "error", 4*time.Second)
// Now make it run successfully for 11 seconds (exit code 0, sleep 11s)
writeControl(t, dir, 0, 11)
// Wait for next retry to start running
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
// Wait for process to run for 10.5 seconds to trigger backoff reset
time.Sleep(10500 * time.Millisecond)
// Now make it crash again (exit code 1, sleep 0s)
writeControl(t, dir, 1, 0)
// Wait for it to finish and crash
assertStatusEventually(t, m, "relay-1", "error", 4*time.Second)
// It crashed. Since it ran for > 10s, backoff should have been reset to 1s.
// We make it healthy again (exit code 0, sleep 5)
writeControl(t, dir, 0, 5)
// Wait 1.5 seconds. If backoff was reset to 1s, it should be running now.
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
m.mu.RLock()
proc := m.processes["relay-1"]
m.mu.RUnlock()
proc.Cancel()
}
func TestUpdateConfigKillsOrphanProcessBeforeRestart(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 5)
cfg := &config.Config{
ServerURL: "http://localhost:8080",
TunnelToken: "test-token",
FrpcPath: scriptPath,
DataDir: dir,
StatePath: filepath.Join(dir, "flared-state.json"),
}
m := NewManager(cfg)
orphan := exec.Command("sh", "-c", "sleep 30")
if err := orphan.Start(); err != nil {
t.Fatalf("failed to start orphan process: %v", err)
}
t.Cleanup(func() {
if orphan.Process != nil {
_ = orphan.Process.Kill()
}
})
pidPath := filepath.Join(dir, "frpc_relay-1.pid")
if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", orphan.Process.Pid)), 0o644); err != nil {
t.Fatalf("failed to seed orphan pid file: %v", err)
}
newConfig := &service.FlaredTunnelConfigResponse{
Version: "1",
Checksum: "sum1",
Relays: []service.FlaredRelayInfo{
{
RelayNodeID: "relay-1",
Address: "127.0.0.1:7000",
AuthToken: "auth-1",
},
},
}
if err := m.UpdateConfig(context.Background(), newConfig); err != nil {
t.Fatalf("failed to UpdateConfig: %v", err)
}
assertCommandExitedEventually(t, orphan, 2*time.Second)
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
m.mu.RLock()
proc := m.processes["relay-1"]
m.mu.RUnlock()
proc.Cancel()
}
func TestStopCancelsRunningProcesses(t *testing.T) {
scriptPath, dir := setupDummyScript(t)
writeControl(t, dir, 0, 30)
cfg := &config.Config{
ServerURL: "http://localhost:8080",
TunnelToken: "test-token",
FrpcPath: scriptPath,
DataDir: dir,
StatePath: filepath.Join(dir, "flared-state.json"),
}
m := NewManager(cfg)
newConfig := &service.FlaredTunnelConfigResponse{
Version: "1",
Checksum: "sum1",
Relays: []service.FlaredRelayInfo{
{
RelayNodeID: "relay-1",
Address: "127.0.0.1:7000",
AuthToken: "auth-1",
},
},
}
if err := m.UpdateConfig(context.Background(), newConfig); err != nil {
t.Fatalf("failed to UpdateConfig: %v", err)
}
assertStatusEventually(t, m, "relay-1", "running", 4*time.Second)
m.mu.RLock()
proc := m.processes["relay-1"]
if proc == nil || proc.Cmd == nil {
m.mu.RUnlock()
t.Fatal("expected running process to have a command handle")
}
cmd := proc.Cmd
m.mu.RUnlock()
m.Stop()
assertCommandExitedEventually(t, cmd, 2*time.Second)
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.processes) != 0 {
t.Fatalf("expected no managed processes after stop, got %d", len(m.processes))
}
}
+28 -96
View File
@@ -3,141 +3,73 @@ package wsclient
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/net/websocket"
"openflare/service"
shared "openflare/utils/wsclient"
)
type WSMessage = shared.WSMessage
type MessageHandler = shared.MessageHandler
type Client struct {
baseURL string
token string
timeout time.Duration
sharedClient *shared.Client
}
type Connection struct {
conn *websocket.Conn
url string
readTimeout time.Duration
sharedConn *shared.Connection
}
func New(baseURL string, token string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: strings.TrimSpace(token),
timeout: timeout,
sharedClient: shared.New(shared.Config{
BaseURL: baseURL,
Token: token,
Timeout: timeout,
HeaderKey: "X-Tunnel-Token",
WSPath: "/api/flared/ws",
}),
}
}
func (c *Client) SetToken(token string) {
c.token = strings.TrimSpace(token)
slog.Debug("flared ws client token updated")
c.sharedClient.SetToken(token)
}
func (c *Client) Connect(ctx context.Context) (*Connection, error) {
wsURL, err := buildWebsocketURL(c.baseURL)
conn, err := c.sharedClient.Connect(ctx)
if err != nil {
return nil, err
}
if strings.TrimSpace(c.token) == "" {
return nil, errors.New("flared ws token is empty")
}
origin := strings.TrimSpace(c.baseURL)
if origin == "" {
origin = "http://localhost"
}
config, err := websocket.NewConfig(wsURL, origin)
if err != nil {
return nil, err
}
config.Header = http.Header{}
config.Header.Set("X-Tunnel-Token", c.token)
if c.timeout > 0 {
config.Dialer = &net.Dialer{Timeout: c.timeout}
}
slog.Debug("flared ws dialing server", "url", wsURL)
conn, err := config.DialContext(ctx)
if err != nil {
return nil, err
}
slog.Debug("flared ws dial succeeded", "url", wsURL)
return &Connection{conn: conn, url: wsURL, readTimeout: websocketReadTimeout(c.timeout)}, nil
return &Connection{sharedConn: conn}, nil
}
func buildWebsocketURL(baseURL string) (string, error) {
parsed, err := url.Parse(strings.TrimRight(baseURL, "/"))
if err != nil {
return "", err
}
switch parsed.Scheme {
case "http":
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
case "ws", "wss":
default:
return "", errors.New("server_url scheme must be http, https, ws, or wss")
}
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/flared/ws"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
func (conn *Connection) SendPing() error {
return conn.sharedConn.SendMessage("ping", nil)
}
func (conn *Connection) SendPong() error {
if conn == nil || conn.conn == nil {
return errors.New("flared ws connection is nil")
}
slog.Debug("flared ws sending pong")
return websocket.JSON.Send(conn.conn, service.WSMessage{
Type: "pong",
})
return conn.sharedConn.SendMessage("pong", nil)
}
func (conn *Connection) Receive() (service.WSMessage, error) {
var message service.WSMessage
if conn == nil || conn.conn == nil {
return message, errors.New("flared ws connection is nil")
}
if conn.readTimeout > 0 {
_ = conn.conn.SetReadDeadline(time.Now().Add(conn.readTimeout))
}
// Use custom json unmarshaling to handle any type
var raw struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload,omitempty"`
}
err := websocket.JSON.Receive(conn.conn, &raw)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
slog.Debug("flared ws receive timeout waiting for server message", "timeout", conn.readTimeout)
}
return message, err
if err := conn.sharedConn.Receive(&raw); err != nil {
return service.WSMessage{}, err
}
message.Type = raw.Type
message.Payload = raw.Payload
slog.Debug("flared ws received message", "type", message.Type)
return message, nil
return service.WSMessage{
Type: raw.Type,
Payload: raw.Payload,
}, nil
}
func websocketReadTimeout(requestTimeout time.Duration) time.Duration {
timeout := requestTimeout * 6
if timeout < 75*time.Second {
return 75 * time.Second
}
return timeout
func (conn *Connection) RunReceiveLoop(ctx context.Context, handler shared.MessageHandler) error {
return conn.sharedConn.RunReceiveLoop(ctx, handler)
}
func (conn *Connection) Close() error {
if conn == nil || conn.conn == nil {
return nil
}
return conn.conn.Close()
return conn.sharedConn.Close()
}