mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-13 05:44:24 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f79fb9969 |
@@ -26,14 +26,11 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
RUN apk add --no-cache bash curl \
|
||||
&& bash scripts/fetch-agent-geoip-mmdb.sh
|
||||
|
||||
FROM openresty/openresty:alpine-slim
|
||||
FROM openresty/openresty:alpine
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata libmaxminddb su-exec libcap \
|
||||
RUN apk add --no-cache ca-certificates tzdata perl libmaxminddb su-exec libcap \
|
||||
&& ln -sf /usr/lib/libmaxminddb.so.0 /usr/lib/libmaxminddb.so \
|
||||
&& apk add --no-cache --virtual .build-deps perl curl \
|
||||
&& opm get anjia0532/lua-resty-maxminddb \
|
||||
&& apk del .build-deps \
|
||||
&& rm -rf /root/.opm \
|
||||
&& addgroup -S openflare \
|
||||
&& adduser -S -G openflare -H -h /data -s /sbin/nologin openflare \
|
||||
&& mkdir -p /etc/openflare /data/etc/openflare \
|
||||
@@ -45,10 +42,13 @@ ENV OPENFLARE_OPENRESTY_PATH=openresty \
|
||||
|
||||
COPY --from=builder /build/bin/openflare-agent /usr/local/bin/openflare-agent
|
||||
# Default agent paths: data_dir/etc/openflare/GeoLite2-*.mmdb
|
||||
COPY --chown=openflare:openflare --chmod=644 --from=builder /build/dist/geoip/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-Country.mmdb
|
||||
COPY --chown=openflare:openflare --chmod=644 --from=builder /build/dist/geoip/GeoLite2-City.mmdb /data/etc/openflare/GeoLite2-City.mmdb
|
||||
COPY --from=builder /build/dist/geoip/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-Country.mmdb
|
||||
COPY --from=builder /build/dist/geoip/GeoLite2-City.mmdb /data/etc/openflare/GeoLite2-City.mmdb
|
||||
RUN chown openflare:openflare /data/etc/openflare/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-City.mmdb \
|
||||
&& chmod 644 /data/etc/openflare/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-City.mmdb
|
||||
|
||||
COPY --chmod=755 scripts/agent-entrypoint.sh /usr/local/bin/openflare-agent-entrypoint.sh
|
||||
COPY scripts/agent-entrypoint.sh /usr/local/bin/openflare-agent-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/openflare-agent-entrypoint.sh
|
||||
|
||||
EXPOSE 80 443 18081
|
||||
ENTRYPOINT ["/usr/local/bin/openflare-agent-entrypoint.sh"]
|
||||
|
||||
+3
-53
@@ -8,57 +8,7 @@ sidebar: false
|
||||
|
||||
格式基于 [Keep a Changelog](http://keepachangelog.com/),版本号遵循 [语义化版本](http://semver.org/)。
|
||||
|
||||
## [v3.5.8] - 2026-10-08
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- 在「系统管理 → 系统配置 → 业务配置」新增可信代理范围设置,默认信任 Cloudflare 官方网段,支持追加、覆盖或关闭;识别出的访客真实 IP 会用于访问日志、WAF 判断和上游请求,并兼容已有自定义 OpenResty 主模板。
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- 修复 Casdoor 等第三方认证源使用字符串用户 ID 时登录回调失败的问题;自动注册不会再与已有本地用户或其他认证源账号发生 ID 冲突,已绑定账号不受影响。
|
||||
- 修复 Agent 在 OpenResty 运行状态异常时可能重复启动并导致端口占用的问题;现可恢复已有运行实例,避免节点配置应用失败。
|
||||
|
||||
## [v3.5.7] - 2026-10-07
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
- Cloudflare 指向分组新增自定义 DNS 目标,可直接填写域名或 IP;原有节点绑定方式仍为默认选项。
|
||||
- Cloudflare 节点分组会在主节点离线或应用失败时自动切换至备用节点,并在主节点恢复后切回;分组详情支持批量开启橙云。
|
||||
- 代理路由详情支持单独关闭 HTTP/2,默认仍启用;关闭后该路由生成的 HTTPS 配置不再启用 HTTP/2。
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- 节点配置应用失败后,Agent 会按指数退避自动强制重试,最长间隔为 5 分钟;成功后停止重试,重启后也会恢复未完成的重试。
|
||||
|
||||
### ⚡️ 优化与改进
|
||||
|
||||
- 优化常见代理访问的响应压缩与上传处理效率,同时保留流式上传的兼容选项。
|
||||
|
||||
### 💄 其他/体验
|
||||
|
||||
- Cloudflare 分组成员操作改为图标按钮,减少操作栏占用空间。
|
||||
- 性能设置的代理选项新增请求体缓冲开关,默认开启;流式上传场景可关闭。
|
||||
|
||||
## [v3.5.6] - 2026-10-01
|
||||
|
||||
### ✨ 新功能
|
||||
- Cloudflare 指向分组:支持将已添加的域名在不同分组之间移动,自动排队更新远程 DNS 指向。
|
||||
- Cloudflare 指向分组:详情页支持批量勾选域名进行批量移动与批量移出操作。
|
||||
- WAF IP 组:在查看 IP 组弹窗中新增即时搜索功能,支持快速过滤和定位 IP 地址。
|
||||
|
||||
### 🛠 修复
|
||||
- 修复 WebDAV 文件存储配置「基础路径」后,上传的文件无法正常访问或删除的问题(路径被重复拼接);升级后历史文件记录同样可正常读写。
|
||||
- 修复本地存储读取历史记录中路径以斜杠开头的文件时报错的问题,现可正常读取。
|
||||
|
||||
## [v3.5.5] - 2026-09-19
|
||||
|
||||
### 🛠 修复
|
||||
- 修复 Cloudflare 指向分组引用的节点已被删除时,分组列表/详情接口整体返回「Cloudflare 资源不存在」的问题;现会跳过缺失节点并继续返回其余分组。
|
||||
- 修复静态导出部署下访问 Cloudflare 指向分组详情(`/cloudflare/groups/{id}`,id 不为 1)会跳回首页并触发 React hydration 报错的问题。
|
||||
|
||||
### ⚡️ 优化与改进
|
||||
- 优化 openflare-agent Docker 镜像体积:精简运行时依赖并消除离线 IP 库冗余层,镜像总体积从 500MB+ 缩减至约 150MB。
|
||||
## [Unreleased]
|
||||
|
||||
## [v3.5.4] - 2026-08-29
|
||||
|
||||
@@ -78,9 +28,9 @@ sidebar: false
|
||||
## 重大变更
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
>
|
||||
>3.5.1 版本解耦了日志存储,ClickHouse 变为可选项,如果想切换数据库, 点击 「任务管理」 -> 「切换日志数据库」任务,按提示迁移数据并切换主库。
|
||||
>
|
||||
>
|
||||
|
||||
|
||||
## [v3.5.3] - 2026-08-13
|
||||
|
||||
@@ -156,6 +156,4 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/unin
|
||||
| --- |---------------------------------------------------------------------------------------------------------|
|
||||
| `agent_token 和 discovery_token 不能同时为空` | 检查 `agent.json` 至少配置了一个 Token |
|
||||
| 节点一直离线 | 在 Agent 节点执行 `curl -I http://your-server:3000`,确认 Server 地址可达 |
|
||||
| 发布后重复失败 | Agent 会按指数退避自动强制同步,初始间隔 10 秒,最长间隔 5 分钟,直到目标版本应用成功;回滚至旧配置也会继续重试。可在节点详情页点击「强制同步」立即触发 |
|
||||
| PID 文件为空或失效,但端口仍被占用 | Agent 会核对 OpenResty master 的进程身份,恢复 PID 文件并重载现有进程;按配置路径和进程父子关系识别本实例,其他 nginx 实例不会阻塞启动。若出现多个匹配的 master,会报告错误并拒绝重复启动 |
|
||||
| OpenResty 重启等待超时 | 重启会先发送优雅退出信号,最多等待 10 秒让本实例旧 master 和 worker 退出(持续跟踪已识别的 worker,即使其被重新托管);超时会保留错误,不会启动第二个实例。检查仍未结束的连接及进程后再重试 |
|
||||
| 发布后重复失败 | Agent 会阻断同一 `version + checksum` 的重复应用;在节点详情页点击「强制同步」,或重新发布新版本 |
|
||||
|
||||
+1
-310
@@ -6362,247 +6362,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/batch-move": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-cloudflare"
|
||||
],
|
||||
"summary": "批量移动 Cloudflare 指向成员",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "原分组 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "批量移动参数",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cloudflare.MemberBatchMoveInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/batch-proxy": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-cloudflare"
|
||||
],
|
||||
"summary": "批量开启 Cloudflare 橙云",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "分组 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "批量开启橙云参数",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cloudflare.MemberBatchProxyInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/batch-remove": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-cloudflare"
|
||||
],
|
||||
"summary": "批量移出 Cloudflare 指向成员",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "分组 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "批量移出参数",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cloudflare.MemberBatchRemoveInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-cloudflare"
|
||||
],
|
||||
"summary": "移动 Cloudflare 指向成员到其他分组",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "原分组 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "成员 ID",
|
||||
"name": "memberId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "目标分组参数",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cloudflare.MemberMoveInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/response.Any"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/cloudflare.MemberItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -14501,7 +14260,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "用户名或密码错误",
|
||||
"description": "用户名或密码错误、帐号已禁用等",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
@@ -15342,15 +15101,6 @@ const docTemplate = `{
|
||||
},
|
||||
"primary_node_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"record_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"record_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -15384,56 +15134,11 @@ const docTemplate = `{
|
||||
"primary_node": {
|
||||
"$ref": "#/definitions/cloudflare.NodeOption"
|
||||
},
|
||||
"record_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"record_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberBatchMoveInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"member_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_group_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberBatchProxyInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"member_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberBatchRemoveInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"member_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberCreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15480,14 +15185,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberMoveInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_group_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberUpdateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19512,9 +19209,6 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
|
||||
}
|
||||
},
|
||||
"enable_http2": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable_https": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -19631,9 +19325,6 @@ const docTemplate = `{
|
||||
"custom_headers": {
|
||||
"type": "string"
|
||||
},
|
||||
"enable_http2": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable_https": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -60,8 +60,6 @@ https://openflare.example.com/login
|
||||
|
||||
如果希望只允许已有用户使用 SSO,可以关闭用户注册。未绑定的第三方账号会进入绑定已有账号流程。
|
||||
|
||||
第三方身份通过「认证源 + `sub`」关联本地用户;自动注册时独立生成本地用户 ID,不使用第三方的 `id` 或数字 `sub` 作为本地用户主键。升级不会修改已有用户 ID 或账号绑定。
|
||||
|
||||
## 修改认证源
|
||||
|
||||
修改认证源时,Client Secret 输入框留空表示保留已有密钥;填写新值则会覆盖保存。
|
||||
@@ -70,12 +68,6 @@ https://openflare.example.com/login
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Casdoor 授权后提示「服务器内部错误,请稍后重试」
|
||||
|
||||
旧版本将 ID Token 中非标准的 `id` 字段按整数解析,而 Casdoor 返回的 `id` 是字符串(通常为 UUID),可能导致授权成功后回调失败。请升级到包含此修复的版本,并从登录页重新发起授权,不要刷新或重复使用旧的回调链接。
|
||||
|
||||
如果升级后仍失败,请在浏览器开发者工具的 Network 中检查 `POST /api/v1/oauth/callback` 的状态码和响应 `error_msg`,以区分令牌兑换、ID Token 校验或数据库错误。排查时不要分享授权码、Token、Client Secret 或完整用户信息。
|
||||
|
||||
### 返回 `invalid_scope`
|
||||
|
||||
说明第三方平台不允许当前配置的 Scope。OIDC 默认 Scope 是 `openid profile email`。请到认证源编辑页调整 Scope,或在第三方平台放行对应 Scope。
|
||||
|
||||
Generated
+8
-22
@@ -283,79 +283,66 @@ packages:
|
||||
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
|
||||
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.60.3':
|
||||
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.60.3':
|
||||
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.60.3':
|
||||
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
|
||||
@@ -556,7 +543,6 @@ packages:
|
||||
'@xmldom/xmldom@0.9.10':
|
||||
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
||||
engines: {node: '>=14.6'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
@@ -911,8 +897,8 @@ packages:
|
||||
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
linkify-it@5.0.2:
|
||||
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
@@ -926,8 +912,8 @@ packages:
|
||||
markdown-it-mathjax3@4.3.2:
|
||||
resolution: {integrity: sha512-TX3GW5NjmupgFtMJGRauioMbbkGsOXAAt1DZ/rzzYmTHqzkO1rNAdiMD4NiruurToPApn2kYy76x02QN26qr2w==}
|
||||
|
||||
markdown-it@14.3.2:
|
||||
resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==}
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
hasBin: true
|
||||
|
||||
markdown-title@1.0.2:
|
||||
@@ -2203,7 +2189,7 @@ snapshots:
|
||||
|
||||
kind-of@6.0.3: {}
|
||||
|
||||
linkify-it@5.0.2:
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
@@ -2222,11 +2208,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
markdown-it@14.3.2:
|
||||
markdown-it@14.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.2
|
||||
linkify-it: 5.0.0
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
@@ -2832,7 +2818,7 @@ snapshots:
|
||||
vitepress-plugin-llms@1.12.2:
|
||||
dependencies:
|
||||
gray-matter: 4.0.3
|
||||
markdown-it: 14.3.2
|
||||
markdown-it: 14.1.1
|
||||
markdown-title: 1.0.2
|
||||
mdast-util-from-markdown: 2.0.3
|
||||
millify: 6.1.0
|
||||
|
||||
@@ -237,7 +237,7 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
|
||||
| `openresty_proxy_read_timeout` | `int` | 源站收到请求后返回数据,Agent 最大的等待数据返回间隔时长(秒) | `60` |
|
||||
| `openresty_websocket_enabled` | `bool` | 是否在 HTTP 段中自动载入渲染支持 WebSocket 协议的全局变量及头信息 | `true` |
|
||||
| `openresty_http3_enabled` | `bool` | 是否在生成 nginx 监听描述中渲染支持 HTTP/3 QUIC 双栈监听能力 | `true` |
|
||||
| `openresty_proxy_request_buffering_enabled`| `bool` | 是否将客户端 Request Body 先在网关做完全部读取缓存再向源站递交;普通代理建议开启,流式上传或实时转发场景可关闭 | `true` |
|
||||
| `openresty_proxy_request_buffering_enabled`| `bool` | 是否将客户端 Request Body 先在网关做完全部读取缓存再向源站递交 | `false` |
|
||||
| `openresty_proxy_buffering_enabled` | `bool` | 是否允许网关暂存源站的大量 Response 数据待全部解析后再转发给用户 | `true` |
|
||||
| `openresty_proxy_buffers` | `string` | nginx 反代响应缓冲区的分配数量与单缓存大大小(如 `16 16k`) | `16 16k` |
|
||||
| `openresty_proxy_buffer_size` | `string` | 存放源站返回 Response Header 头部信息的专属缓冲区限制 | `8k` |
|
||||
@@ -259,9 +259,6 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
|
||||
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
|
||||
| `openresty_default_limit_req_per_ip` | `string` | 站点未配置时的默认单 IP 请求频率限制(如 `10r/s`、`100r/m`);空表示默认关闭 | 空 |
|
||||
| `openresty_main_config_template` | `string` | 允许用户完全重写整个 OpenResty nginx.conf 的底层结构大骨架模板 | 空 (内置缺省骨架) |
|
||||
| `openresty_trusted_proxy_cidrs` | `string` (JSON 数组) | 可读取 `CF-Connecting-IP` 的可信代理 IPv4/IPv6 CIDR 列表;默认包含 Cloudflare 官方 22 个 IPv4/IPv6 网段;显式 `[]` 会关闭自动生成的可信代理配置 | Cloudflare 官方网段 |
|
||||
|
||||
在管理端「系统管理 → 系统配置 → 业务配置」中编辑 `openresty_trusted_proxy_cidrs`。默认值为 Cloudflare 官方完整 22 个 IPv4/IPv6 网段,可从 [Cloudflare IPv4 网段](https://www.cloudflare.com/ips-v4) 和 [IPv6 网段](https://www.cloudflare.com/ips-v6) 核对并按官方更新维护。若要信任额外内网代理,应将其 CIDR 追加到现有数组;例如 `10.2.0.227` 只有在确认它是可信代理、且它发送的 `CF-Connecting-IP` 真实可靠后,才追加 `10.2.0.227/32`。该地址不属于默认信任范围。显式设置为 `[]` 会关闭由此配置生成的转发头信任;已有显式空值不会在升级时自动改为默认。自定义主模板中手工写入的 Real-IP 指令仍按模板生效。源站请求必须带有真实的 `CF-Connecting-IP`。保存后需重新生成并发布节点配置版本。Nginx Real-IP 会按 `CF-Connecting-IP` 更新 `$remote_addr`,访问日志、WAF 和上游 `X-Real-IP` 使用这个单一客户端地址;`X-Forwarded-For` 保留原代理链并追加恢复后的客户端地址。旧版自定义主模板即使没有新占位符,也会在 `http {}` 中自动插入 Real-IP 指令;若模板已有 `real_ip_header`,该模板控制使用的头,设置仅追加可信来源 CIDR。数据库中缺少该配置项时使用 Cloudflare 默认网段;非法 JSON 或读取配置失败时按空列表关闭信任。
|
||||
|
||||
### 7. 源站错误页 (Origin Error Page)
|
||||
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
# Cloudflare 分组域名移动/批量操作与 IP 组查看搜索实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 `/cloudflare/groups/[id]` 页面增加域名单条移动、批量移动与批量移出操作及后端支持,并为 `/ip-groups` 的查看 IP 组弹窗增加 IP 即时搜索过滤功能。
|
||||
|
||||
**Architecture:** 后端在 `internal/apps/openflare/cloudflare` 扩展单条移动及批量移动/删除接口并排队更新 DNS 同步;前端在服务层封装调用,在详情页增加多选框、批量工具栏与移动弹窗;IP 组查看弹窗增加输入框实时过滤 entries。
|
||||
|
||||
**Tech Stack:** Go 1.25+, Gin, GORM, Next.js App Router, TypeScript, React Query, Tailwind CSS, shadcn/ui, Vitest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 禁止删除 `frontend/node_modules`。
|
||||
- 遵循 `AGENTS.md`:API 错误使用 `response.Abort*`,禁止使用 200 返回错误体。
|
||||
- 业务路由仅在 `internal/router/v1/openflare/register_cloudflare.go` 挂载。
|
||||
- i18n 变更修改 `frontend/messages/fragments`,通过 `node scripts/merge-i18n-fragments.mjs` 合并,禁止直接修改 `zh-CN.json` / `en.json`。
|
||||
- 完成后必须执行 `make swagger` 与 `make code-check`。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 DTO、业务逻辑及单测 (Backend Logic & Unit Tests)
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/cloudflare/types.go`
|
||||
- Modify: `internal/apps/openflare/cloudflare/errs.go`
|
||||
- Modify: `internal/apps/openflare/cloudflare/logics.go`
|
||||
- Modify: `internal/apps/openflare/cloudflare/logics_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `MemberMoveInput { TargetGroupID uint `json:"target_group_id"` }`
|
||||
- `MemberBatchMoveInput { MemberIDs []uint `json:"member_ids"`, TargetGroupID uint `json:"target_group_id"` }`
|
||||
- `MemberBatchRemoveInput { MemberIDs []uint `json:"member_ids"` }`
|
||||
- `MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error)`
|
||||
- `BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error`
|
||||
- `BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error`
|
||||
|
||||
- [ ] **Step 1: 在 types.go 和 errs.go 中新增结构体与错误常量**
|
||||
|
||||
在 `internal/apps/openflare/cloudflare/types.go` 增加:
|
||||
```go
|
||||
// MemberMoveInput contains the target group ID for moving a member.
|
||||
type MemberMoveInput struct {
|
||||
TargetGroupID uint `json:"target_group_id"`
|
||||
}
|
||||
|
||||
// MemberBatchMoveInput contains the member IDs and target group ID for batch moving.
|
||||
type MemberBatchMoveInput struct {
|
||||
MemberIDs []uint `json:"member_ids"`
|
||||
TargetGroupID uint `json:"target_group_id"`
|
||||
}
|
||||
|
||||
// MemberBatchRemoveInput contains the member IDs for batch deletion.
|
||||
type MemberBatchRemoveInput struct {
|
||||
MemberIDs []uint `json:"member_ids"`
|
||||
}
|
||||
```
|
||||
|
||||
在 `internal/apps/openflare/cloudflare/errs.go` 增加:
|
||||
```go
|
||||
errTargetGroupSame = "目标分组不能为当前分组"
|
||||
errTargetGroupInvalid = "目标分组不存在"
|
||||
errNoMembersSelected = "未选择任何成员"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写失败的单元测试**
|
||||
|
||||
在 `internal/apps/openflare/cloudflare/logics_test.go` 中新增测试函数 `TestMoveMemberAndBatchOperations(t *testing.T)`,测试:
|
||||
1. 目标分组等于源分组时报错 `errTargetGroupSame`;
|
||||
2. 目标分组不存在时报错;
|
||||
3. 成功移动成员到目标分组,更新 `GroupID` 并将 `SyncStatus` 置为 `pending`;
|
||||
4. 批量移动多个成员到目标分组;
|
||||
5. 批量删除成员。
|
||||
|
||||
- [ ] **Step 3: 运行测试验证失败**
|
||||
|
||||
运行:`go test -v ./internal/apps/openflare/cloudflare -run TestMoveMemberAndBatchOperations`
|
||||
预期:编译错误或未实现失败。
|
||||
|
||||
- [ ] **Step 4: 实现 MoveMember, BatchMoveMembers, BatchRemoveMembers**
|
||||
|
||||
在 `internal/apps/openflare/cloudflare/logics.go` 实现:
|
||||
```go
|
||||
// MoveMember transfers a member from sourceGroupID to targetGroupID.
|
||||
func MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error) {
|
||||
if targetGroupID == 0 || targetGroupID == sourceGroupID {
|
||||
return nil, errors.New(errTargetGroupSame)
|
||||
}
|
||||
targetGroup, err := repository.GetCFPointingGroup(ctx, targetGroupID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New(errTargetGroupInvalid)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
member.GroupID = targetGroupID
|
||||
member.SyncStatus = model.CFMemberSyncPending
|
||||
member.LastError = ""
|
||||
if err = repository.SaveCFPointingMember(ctx, member); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetGroup.Enabled {
|
||||
if _, err = DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); err != nil {
|
||||
logger.WarnF(ctx, "[Cloudflare] dispatch move sync failed: member_id=%d error=%v", member.ID, err)
|
||||
}
|
||||
}
|
||||
domain, err := repository.GetZoneDomainByID(ctx, member.ZoneDomainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return memberItem(member, domain), nil
|
||||
}
|
||||
|
||||
// BatchMoveMembers transfers multiple members from sourceGroupID to targetGroupID.
|
||||
func BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error {
|
||||
if len(input.MemberIDs) == 0 {
|
||||
return errors.New(errNoMembersSelected)
|
||||
}
|
||||
if input.TargetGroupID == 0 || input.TargetGroupID == sourceGroupID {
|
||||
return errors.New(errTargetGroupSame)
|
||||
}
|
||||
targetGroup, err := repository.GetCFPointingGroup(ctx, input.TargetGroupID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New(errTargetGroupInvalid)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, memberID := range input.MemberIDs {
|
||||
member, getErr := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||
if getErr != nil {
|
||||
continue
|
||||
}
|
||||
member.GroupID = input.TargetGroupID
|
||||
member.SyncStatus = model.CFMemberSyncPending
|
||||
member.LastError = ""
|
||||
if saveErr := repository.SaveCFPointingMember(ctx, member); saveErr != nil {
|
||||
logger.ErrorF(ctx, "[Cloudflare] batch move save member failed: member_id=%d error=%v", memberID, saveErr)
|
||||
continue
|
||||
}
|
||||
if targetGroup.Enabled {
|
||||
if _, syncErr := DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); syncErr != nil {
|
||||
logger.WarnF(ctx, "[Cloudflare] dispatch batch move sync failed: member_id=%d error=%v", member.ID, syncErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchRemoveMembers deletes multiple members and their remote A records.
|
||||
func BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error {
|
||||
if len(input.MemberIDs) == 0 {
|
||||
return errors.New(errNoMembersSelected)
|
||||
}
|
||||
for _, memberID := range input.MemberIDs {
|
||||
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if delErr := DeleteManagedRecord(ctx, member.ID); delErr != nil {
|
||||
logger.WarnF(ctx, "[Cloudflare] delete remote record failed during batch remove: member_id=%d error=%v", member.ID, delErr)
|
||||
}
|
||||
if err = repository.DeleteCFPointingMember(ctx, member); err != nil {
|
||||
logger.ErrorF(ctx, "[Cloudflare] delete member failed during batch remove: member_id=%d error=%v", member.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试验证通过**
|
||||
|
||||
运行:`go test -v ./internal/apps/openflare/cloudflare -run TestMoveMemberAndBatchOperations`
|
||||
预期:PASS。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/cloudflare/
|
||||
git commit -m "feat(cloudflare): add move and batch operation logics with tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 Handler、路由注册与 Swagger (Backend Handlers & Routing)
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/cloudflare/routers.go`
|
||||
- Modify: `internal/apps/openflare/cloudflare/routers_test.go`
|
||||
- Modify: `internal/router/v1/openflare/register_cloudflare.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `MoveMemberHandler(c *gin.Context)`
|
||||
- `BatchMoveMembersHandler(c *gin.Context)`
|
||||
- `BatchRemoveMembersHandler(c *gin.Context)`
|
||||
- 路由:`POST /groups/:id/members/:memberId/move`
|
||||
- 路由:`POST /groups/:id/members/batch-move`
|
||||
- 路由:`POST /groups/:id/members/batch-remove`
|
||||
|
||||
- [ ] **Step 1: 在 routers.go 中实现 Handler 并补充 Swagger 注解**
|
||||
|
||||
```go
|
||||
// MoveMemberHandler moves a member to a target group.
|
||||
// @Summary 移动 Cloudflare 指向成员到其他分组
|
||||
// @Tags openflare-cloudflare
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "原分组 ID"
|
||||
// @Param memberId path int true "成员 ID"
|
||||
// @Param body body cloudflare.MemberMoveInput true "目标分组参数"
|
||||
// @Success 200 {object} response.Any{data=cloudflare.MemberItem}
|
||||
// @Failure 400 {object} response.Any
|
||||
// @Failure 404 {object} response.Any
|
||||
// @Router /api/v1/d/cloudflare/groups/{id}/members/{memberId}/move [post]
|
||||
func MoveMemberHandler(c *gin.Context) {
|
||||
groupID, memberID, ok := memberParams(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input MemberMoveInput
|
||||
if !apiutil.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
item, err := MoveMember(c.Request.Context(), groupID, memberID, input.TargetGroupID)
|
||||
if abortLogic(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(item))
|
||||
}
|
||||
|
||||
// BatchMoveMembersHandler moves multiple members to a target group.
|
||||
// @Summary 批量移动 Cloudflare 指向成员
|
||||
// @Tags openflare-cloudflare
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "原分组 ID"
|
||||
// @Param body body cloudflare.MemberBatchMoveInput true "批量移动参数"
|
||||
// @Success 200 {object} response.Any
|
||||
// @Failure 400 {object} response.Any
|
||||
// @Failure 404 {object} response.Any
|
||||
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-move [post]
|
||||
func BatchMoveMembersHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input MemberBatchMoveInput
|
||||
if !apiutil.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
if abortLogic(c, BatchMoveMembers(c.Request.Context(), id, input)) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
|
||||
// BatchRemoveMembersHandler removes multiple members.
|
||||
// @Summary 批量移出 Cloudflare 指向成员
|
||||
// @Tags openflare-cloudflare
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "分组 ID"
|
||||
// @Param body body cloudflare.MemberBatchRemoveInput true "批量移出参数"
|
||||
// @Success 200 {object} response.Any
|
||||
// @Failure 400 {object} response.Any
|
||||
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-remove [post]
|
||||
func BatchRemoveMembersHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input MemberBatchRemoveInput
|
||||
if !apiutil.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
if abortLogic(c, BatchRemoveMembers(c.Request.Context(), id, input)) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 register_cloudflare.go 中挂载路由**
|
||||
|
||||
在 `internal/router/v1/openflare/register_cloudflare.go` 添加:
|
||||
```go
|
||||
route.POST("/groups/:id/members/:memberId/move", cf.MoveMemberHandler)
|
||||
route.POST("/groups/:id/members/batch-move", cf.BatchMoveMembersHandler)
|
||||
route.POST("/groups/:id/members/batch-remove", cf.BatchRemoveMembersHandler)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 routers_test.go 中补充接口级单测**
|
||||
|
||||
为新 Handler 添加单元测试,验证请求解析与响应状态码。
|
||||
运行:`go test -v ./internal/apps/openflare/cloudflare -run TestRouters`
|
||||
预期:PASS。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/cloudflare/routers.go internal/apps/openflare/cloudflare/routers_test.go internal/router/v1/openflare/register_cloudflare.go
|
||||
git commit -m "feat(cloudflare): register move and batch member API routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: WAF IP 组「查看 IP 组」弹窗搜索功能 (IP Group View Dialog Search)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/messages/fragments/security.zh-CN.json`
|
||||
- Modify: `frontend/messages/fragments/security.en.json`
|
||||
- Modify: `frontend/app/(main)/waf/components/ip-group-view-dialog.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- 搜索框 Input,支持清空按钮
|
||||
- `filteredEntries` 动态过滤
|
||||
- 数量摘要显示,如 `共 10 条 IP(匹配 3 条)`
|
||||
- 过滤无匹配时的独立空状态提示
|
||||
|
||||
- [ ] **Step 1: 在 security.zh-CN.json 与 security.en.json 中补充文案**
|
||||
|
||||
在 `frontend/messages/fragments/security.zh-CN.json` 中的 `ipGroups.viewDialog` 增加:
|
||||
```json
|
||||
"searchPlaceholder": "搜索 IP 地址...",
|
||||
"summaryFiltered": "{type} · 共 {count} 条 IP(匹配 {matched} 条)",
|
||||
"noSearchResult": "未找到匹配的 IP 地址。"
|
||||
```
|
||||
并在 `security.en.json` 对应增加英文文案。
|
||||
执行 `node scripts/merge-i18n-fragments.mjs` 同步生成全量文件。
|
||||
|
||||
- [ ] **Step 2: 修改 ip-group-view-dialog.tsx**
|
||||
|
||||
增加 `searchKeyword` state:
|
||||
- 渲染包含 `Search` 图标和清空按钮的 Input;
|
||||
- `filteredEntries`:使用 `entries.filter(e => e.ip.toLowerCase().includes(keyword.trim().toLowerCase()))`;
|
||||
- 弹窗关闭时自动清空 `searchKeyword`;
|
||||
- 当 `entries.length > 0` 且 `filteredEntries.length === 0` 时展示 `EmptyStateWithBorder`(使用 `noSearchResult`);
|
||||
- DialogDescription 中根据是否有过滤展示 `summaryFiltered` 或 `summary`。
|
||||
|
||||
- [ ] **Step 3: 运行 TypeScript 检查**
|
||||
|
||||
运行:`cd frontend && pnpm tsc --noEmit`
|
||||
预期:无类型错误。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/messages/ frontend/app/\(main\)/waf/components/ip-group-view-dialog.tsx
|
||||
git commit -m "feat(waf): add search and filter in ip group view dialog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 Cloudflare 分组单条移动与批量操作 (Frontend Move & Batch Operations)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/messages/fragments/cloudflare.zh-CN.json`
|
||||
- Modify: `frontend/messages/fragments/cloudflare.en.json`
|
||||
- Modify: `frontend/lib/services/openflare/cloudflare.service.ts`
|
||||
- Create: `frontend/app/(main)/cloudflare/components/member-move-dialog.tsx`
|
||||
- Modify: `frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx`
|
||||
- Modify: `frontend/tests/cloudflare/cloudflare-group-detail.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `CloudflareService.moveMember(groupId, memberId, targetGroupId)`
|
||||
- `CloudflareService.batchMoveMembers(groupId, memberIds, targetGroupId)`
|
||||
- `CloudflareService.batchRemoveMembers(groupId, memberIds)`
|
||||
- `<MemberMoveDialog />` 组件
|
||||
- 多选框 Checkbox、全选/半选/清空、批量操作工具条
|
||||
|
||||
- [ ] **Step 1: 在 cloudflare.zh-CN.json 与 cloudflare.en.json 增加国际化文案**
|
||||
|
||||
增加:
|
||||
- `move`: "移动"
|
||||
- `batchMove`: "批量移动"
|
||||
- `batchRemove`: "批量删除"
|
||||
- `selectedCount`: "已选择 {count} 项"
|
||||
- `clearSelection`: "取消选择"
|
||||
- `moveDialog`:
|
||||
- `titleSingle`: "移动域名「{domain}」"
|
||||
- `titleBatch`: "批量移动域名(共 {count} 项)"
|
||||
- `targetGroupLabel`: "目标指向分组"
|
||||
- `targetGroupPlaceholder`: "请选择目标分组"
|
||||
- `noOtherGroups`: "没有其他可用的指向分组,请先创建分组"
|
||||
- `submit`: "确认移动"
|
||||
- `moving`: "移动中..."
|
||||
- `batchRemoveDialog`:
|
||||
- `title`: "确认批量删除域名"
|
||||
- `desc`: "确认从当前分组移出已选中的 {count} 个域名吗?这将同时清理 Cloudflare 上的解析记录。"
|
||||
- `confirm`: "确认删除"
|
||||
- `removing`: "删除中..."
|
||||
- `memberMoved`: "域名移动成功"
|
||||
- `batchMoved`: "成功移动 {count} 个域名"
|
||||
- `batchRemoved`: "成功删除 {count} 个域名"
|
||||
|
||||
执行 `node scripts/merge-i18n-fragments.mjs` 同步。
|
||||
|
||||
- [ ] **Step 2: 在 cloudflare.service.ts 扩展 API 方法**
|
||||
|
||||
```typescript
|
||||
static async moveMember(
|
||||
groupId: number,
|
||||
memberId: number,
|
||||
targetGroupId: number,
|
||||
): Promise<CloudflareMember> {
|
||||
return this.post<CloudflareMember>(
|
||||
`/cloudflare/groups/${groupId}/members/${memberId}/move`,
|
||||
{ target_group_id: targetGroupId },
|
||||
);
|
||||
}
|
||||
|
||||
static async batchMoveMembers(
|
||||
groupId: number,
|
||||
memberIds: number[],
|
||||
targetGroupId: number,
|
||||
): Promise<void> {
|
||||
return this.post<void>(
|
||||
`/cloudflare/groups/${groupId}/members/batch-move`,
|
||||
{ member_ids: memberIds, target_group_id: targetGroupId },
|
||||
);
|
||||
}
|
||||
|
||||
static async batchRemoveMembers(
|
||||
groupId: number,
|
||||
memberIds: number[],
|
||||
): Promise<void> {
|
||||
return this.post<void>(
|
||||
`/cloudflare/groups/${groupId}/members/batch-remove`,
|
||||
{ member_ids: memberIds },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 创建 MemberMoveDialog 组件**
|
||||
|
||||
在 `frontend/app/(main)/cloudflare/components/member-move-dialog.tsx`:
|
||||
- Props: `open`, `onOpenChange`, `members: CloudflareMember[]`, `groups: CloudflareGroup[]`, `currentGroupId: number`, `pending: boolean`, `onSubmit: (targetGroupId: number) => void`
|
||||
- 过滤 `availableGroups = groups.filter(g => g.id !== currentGroupId)`
|
||||
- 下拉选择 Select 组件展示分组名称与主节点 IP
|
||||
- 提交按钮
|
||||
|
||||
- [ ] **Step 4: 修改 CloudflareGroupDetailPageClient 实现多选与批量/单条操作**
|
||||
|
||||
在 `page-client.tsx`:
|
||||
- 使用 React Query 获取全部分组列表 `CloudflareService.listGroups()`;
|
||||
- 状态管理:
|
||||
- `selectedMemberIDs: Set<number>`
|
||||
- `moveDialogOpen: boolean`
|
||||
- `movingMembers: CloudflareMember[]`
|
||||
- `batchRemoveOpen: boolean`
|
||||
- 多选逻辑:
|
||||
- 表头 Checkbox 绑定当前页成员选中状态(`allSelected`, `indeterminate`);
|
||||
- 每行首列增加 Checkbox;
|
||||
- 批量工具栏:
|
||||
- 在卡片内部当 `selectedMemberIDs.size > 0` 时展示浮动/嵌入的操作栏:包含数量、`批量移动`、`批量删除`、`取消选择`;
|
||||
- 行操作区:
|
||||
- 在原有 `同步`、`删除` 旁新增 `移动` 按钮(FolderInput 或 ArrowRightLeft 图标),点击打开 `MemberMoveDialog`(传入单条成员);
|
||||
- Mutations:
|
||||
- `moveMutation`:支持单条和批量移动,完成后清空 `selectedMemberIDs` 并刷新 query;
|
||||
- `batchRemoveMutation`:批量移出,完成后清空并刷新。
|
||||
|
||||
- [ ] **Step 5: 更新前端测试**
|
||||
|
||||
在 `frontend/tests/cloudflare/cloudflare-group-detail.test.tsx` 增加测试用例覆盖移动与批量删除触发逻辑。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/
|
||||
git commit -m "feat(cloudflare): add domain move and batch operations in group detail page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 质量门禁、Swagger 与更新日志 (Quality Gates & Verification)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/changelog/index.md`
|
||||
- Generated: `internal/router/root/swagger/` (via make swagger)
|
||||
|
||||
- [ ] **Step 1: 生成 Swagger API 文档**
|
||||
|
||||
运行:`make swagger`
|
||||
检查生成的 swagger 规范包含新端点。
|
||||
|
||||
- [ ] **Step 2: 执行全量代码检查**
|
||||
|
||||
运行:`make code-check`
|
||||
确保 `golangci-lint`、`tsc` 与 `eslint` 均零警告零错误通过。
|
||||
|
||||
- [ ] **Step 3: 更新 docs/changelog/index.md**
|
||||
|
||||
在 `[Unreleased]` 下添加新增功能说明:
|
||||
```markdown
|
||||
### 新增
|
||||
- Cloudflare 指向分组:支持将已添加的域名在不同分组之间移动,自动排队更新远程 DNS 指向。
|
||||
- Cloudflare 指向分组:详情页支持批量勾选域名进行批量移动与批量移出操作。
|
||||
- WAF IP 组:在查看 IP 组弹窗中新增即时搜索功能,支持快速过滤和定位 IP 地址。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交变更**
|
||||
|
||||
```bash
|
||||
git add docs/changelog/index.md internal/router/root/swagger/
|
||||
git commit -m "docs(changelog): document cloudflare member move and ip group search"
|
||||
```
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
# Cloudflare 分组域名移动/批量操作与 IP 组查看搜索设计文档
|
||||
|
||||
- **日期**:2026-09-24
|
||||
- **状态**:已批准 (Approved)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与背景 (Goals & Context)
|
||||
|
||||
1. **Cloudflare 分组域名管理增强**:
|
||||
- 在 `/cloudflare/groups/[id]` 页面,为单个成员域名增加「移动」操作,支持迁移至其他可用分组;
|
||||
- 增加表格行复选框多选机制与批量操作栏,支持「批量删除」与「批量移动」;
|
||||
- 确保域名移动后,如果目标分组处于启用状态,自动重新排队向 Cloudflare 同步目标节点的新 IP。
|
||||
2. **WAF IP 组查看弹窗增强**:
|
||||
- 在 `/ip-groups` 的「查看 IP 组」弹窗(`IPGroupViewDialog`)内,新增即时 IP 搜索与过滤功能,方便用户在包含大量 IP 条目时快速查找与定位。
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端接口设计 (Backend API)
|
||||
|
||||
### 2.1 数据模型与 DTO
|
||||
|
||||
文件:`internal/apps/openflare/cloudflare/types.go`
|
||||
|
||||
```go
|
||||
type MemberMoveInput struct {
|
||||
TargetGroupID uint `json:"target_group_id"`
|
||||
}
|
||||
|
||||
type MemberBatchMoveInput struct {
|
||||
MemberIDs []uint `json:"member_ids"`
|
||||
TargetGroupID uint `json:"target_group_id"`
|
||||
}
|
||||
|
||||
type MemberBatchRemoveInput struct {
|
||||
MemberIDs []uint `json:"member_ids"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 路由端点
|
||||
|
||||
文件:`internal/apps/openflare/cloudflare/routers.go`
|
||||
注册:`internal/router/v1/openflare/register_cloudflare.go`
|
||||
|
||||
1. **单条移动**:
|
||||
- 路径:`POST /api/v1/d/cloudflare/groups/:id/members/:memberId/move`
|
||||
- 请求体:`MemberMoveInput`
|
||||
- 响应:`200 OK` + `cloudflare.MemberItem`
|
||||
2. **批量移动**:
|
||||
- 路径:`POST /api/v1/d/cloudflare/groups/:id/members/batch-move`
|
||||
- 请求体:`MemberBatchMoveInput`
|
||||
- 响应:`200 OK`
|
||||
3. **批量移出/删除**:
|
||||
- 路径:`POST /api/v1/d/cloudflare/groups/:id/members/batch-remove`
|
||||
- 请求体:`MemberBatchRemoveInput`
|
||||
- 响应:`200 OK`
|
||||
|
||||
### 2.3 业务逻辑层
|
||||
|
||||
文件:`internal/apps/openflare/cloudflare/logics.go`
|
||||
|
||||
- `MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error)`
|
||||
- 检查 `targetGroupID != sourceGroupID`;
|
||||
- 检查目标分组是否存在且非空;
|
||||
- 查询原成员,更新 `GroupID = targetGroupID`,将 `SyncStatus` 置为 `pending`,清除 `LastError`,保存到数据库;
|
||||
- 若目标分组 `Enabled == true`,调用 `DispatchMemberSync(ctx, member.ID, "cloudflare_member_move")` 排队执行远程 A 记录同步;
|
||||
- 返回更新后的 `MemberItem`。
|
||||
- `BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error`
|
||||
- 校验目标分组有效性;
|
||||
- 针对 `input.MemberIDs` 过滤去重,对每个成员执行分组归属更新并重置同步状态;
|
||||
- 若目标分组启用,分发同步任务。
|
||||
- `BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error`
|
||||
- 针对每个 `memberID`,校验属于 `sourceGroupID`,调用 `DeleteManagedRecord(ctx, member.ID)` 清除 Cloudflare 上的 A 记录,并调用 `repository.DeleteCFPointingMember(ctx, member)`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端交互设计 (Frontend UI & Interaction)
|
||||
|
||||
### 3.1 Cloudflare 服务客户端扩展
|
||||
|
||||
文件:`frontend/lib/services/openflare/cloudflare.service.ts`
|
||||
|
||||
- `moveMember(groupId: number, memberId: number, targetGroupId: number): Promise<CloudflareMember>`
|
||||
- `batchMoveMembers(groupId: number, memberIds: number[], targetGroupId: number): Promise<void>`
|
||||
- `batchRemoveMembers(groupId: number, memberIds: number[]): Promise<void>`
|
||||
|
||||
### 3.2 域名成员列表交互 (`frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx`)
|
||||
|
||||
1. **多选管理**:
|
||||
- `selectedMemberIDs: Set<number>` 状态;
|
||||
- 表头 Checkbox 支持全部选中/取消选中(含半选状态);
|
||||
- 每行首列为 Checkbox,行内可单选。
|
||||
2. **批量操作栏 (Batch Action Bar)**:
|
||||
- 当 `selectedMemberIDs.size > 0` 时,在成员卡片头部或列表上方显示批量操作工具条:
|
||||
- 已选条数提示(Badge);
|
||||
- 「批量移动」按钮:唤起移动弹窗,包含选中的所有成员;
|
||||
- 「批量删除」按钮:唤起确认弹窗,明确提示删除的数量与域名,确认后执行批量移出;
|
||||
- 「取消选择」按钮:清空勾选。
|
||||
3. **行级「移动」操作**:
|
||||
- 行右侧操作按钮区新增「移动」操作按钮;
|
||||
- 点击唤起 `MemberMoveDialog` 弹窗。
|
||||
4. **移动域名弹窗 (`MemberMoveDialog`)**:
|
||||
- 提取独立组件或子组件,接收待移动的成员列表和全部 Cloudflare 分组列表;
|
||||
- 过滤排除当前分组,用户下拉选择目标分组;
|
||||
- 点击确认后执行 `moveMutation` 或 `batchMoveMutation`。
|
||||
|
||||
### 3.3 WAF IP 组查看弹窗搜索 (`frontend/app/(main)/waf/components/ip-group-view-dialog.tsx`)
|
||||
|
||||
1. **搜索框**:
|
||||
- 在 DialogHeader 和 IP 列表表格之间,增加搜索过滤条;
|
||||
- 带 `Search` 图标,清除按钮,响应输入并去除首尾空格;
|
||||
2. **过滤行为**:
|
||||
- `searchKeyword` 过滤 `entries` 列表,匹配 `entry.ip`;
|
||||
- 更新汇总文案:如 `共 50 条 IP (匹配 3 条)`;
|
||||
- 无匹配时展示特定空状态「未找到匹配的 IP 地址」。
|
||||
|
||||
---
|
||||
|
||||
## 4. 国际化与文案 (i18n)
|
||||
|
||||
- `frontend/messages/fragments/cloudflare.zh-CN.json` & `cloudflare.en.json`:
|
||||
- 增加移动相关文案:`move`, `batchMove`, `batchRemove`, `moveTitle`, `targetGroup`, `selectTargetGroup`, `batchRemoveConfirm`, 等。
|
||||
- `frontend/messages/fragments/security.zh-CN.json` & `security.en.json`:
|
||||
- 增加 `viewDialog` 搜索框文案:`searchPlaceholder`, `matchedCount`, `noMatchFilter`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 验证标准 (Verification Criteria)
|
||||
|
||||
1. **后端单测**:
|
||||
- 针对 `MoveMember`、`BatchMoveMembers`、`BatchRemoveMembers` 编写完整单元测试,验证成功与异常分支(如移到同一分组报错、不存在的分组报错等);
|
||||
- 执行 `go test ./internal/apps/openflare/cloudflare/...` 全部通过。
|
||||
2. **前端测试与校验**:
|
||||
- 更新或新增前端测试用例覆盖移动与批量删除逻辑;
|
||||
- 执行 `make code-check` 保证 TypeScript、ESLint 与后端架构规则通过。
|
||||
3. **API 文档与更新日志**:
|
||||
- 执行 `make swagger` 同步 OpenAPI 文档;
|
||||
- 更新 `docs/changelog/index.md`。
|
||||
+1
-310
@@ -6355,247 +6355,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/batch-move": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-cloudflare"
|
||||
],
|
||||
"summary": "批量移动 Cloudflare 指向成员",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "原分组 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "批量移动参数",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cloudflare.MemberBatchMoveInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/batch-proxy": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-cloudflare"
|
||||
],
|
||||
"summary": "批量开启 Cloudflare 橙云",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "分组 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "批量开启橙云参数",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cloudflare.MemberBatchProxyInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/batch-remove": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-cloudflare"
|
||||
],
|
||||
"summary": "批量移出 Cloudflare 指向成员",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "分组 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "批量移出参数",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cloudflare.MemberBatchRemoveInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"openflare-cloudflare"
|
||||
],
|
||||
"summary": "移动 Cloudflare 指向成员到其他分组",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "原分组 ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "成员 ID",
|
||||
"name": "memberId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "目标分组参数",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/cloudflare.MemberMoveInput"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/response.Any"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/cloudflare.MemberItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -14494,7 +14253,7 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "用户名或密码错误",
|
||||
"description": "用户名或密码错误、帐号已禁用等",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Any"
|
||||
}
|
||||
@@ -15335,15 +15094,6 @@
|
||||
},
|
||||
"primary_node_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"record_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"record_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_mode": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -15377,56 +15127,11 @@
|
||||
"primary_node": {
|
||||
"$ref": "#/definitions/cloudflare.NodeOption"
|
||||
},
|
||||
"record_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"record_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberBatchMoveInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"member_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_group_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberBatchProxyInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"member_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberBatchRemoveInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"member_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberCreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15473,14 +15178,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberMoveInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_group_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudflare.MemberUpdateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19505,9 +19202,6 @@
|
||||
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
|
||||
}
|
||||
},
|
||||
"enable_http2": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable_https": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -19624,9 +19318,6 @@
|
||||
"custom_headers": {
|
||||
"type": "string"
|
||||
},
|
||||
"enable_http2": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable_https": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
+1
-195
@@ -330,12 +330,6 @@ definitions:
|
||||
type: string
|
||||
primary_node_id:
|
||||
type: integer
|
||||
record_content:
|
||||
type: string
|
||||
record_type:
|
||||
type: string
|
||||
target_mode:
|
||||
type: string
|
||||
type: object
|
||||
cloudflare.GroupItem:
|
||||
properties:
|
||||
@@ -357,38 +351,9 @@ definitions:
|
||||
type: string
|
||||
primary_node:
|
||||
$ref: '#/definitions/cloudflare.NodeOption'
|
||||
record_content:
|
||||
type: string
|
||||
record_type:
|
||||
type: string
|
||||
target_mode:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
cloudflare.MemberBatchMoveInput:
|
||||
properties:
|
||||
member_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
target_group_id:
|
||||
type: integer
|
||||
type: object
|
||||
cloudflare.MemberBatchProxyInput:
|
||||
properties:
|
||||
member_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
type: object
|
||||
cloudflare.MemberBatchRemoveInput:
|
||||
properties:
|
||||
member_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
type: object
|
||||
cloudflare.MemberCreateInput:
|
||||
properties:
|
||||
proxied:
|
||||
@@ -419,11 +384,6 @@ definitions:
|
||||
zone_id:
|
||||
type: integer
|
||||
type: object
|
||||
cloudflare.MemberMoveInput:
|
||||
properties:
|
||||
target_group_id:
|
||||
type: integer
|
||||
type: object
|
||||
cloudflare.MemberUpdateInput:
|
||||
properties:
|
||||
proxied:
|
||||
@@ -3082,8 +3042,6 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/proxy_route.CustomHeaderInput'
|
||||
type: array
|
||||
enable_http2:
|
||||
type: boolean
|
||||
enable_https:
|
||||
type: boolean
|
||||
enabled:
|
||||
@@ -3161,8 +3119,6 @@ definitions:
|
||||
type: array
|
||||
custom_headers:
|
||||
type: string
|
||||
enable_http2:
|
||||
type: boolean
|
||||
enable_https:
|
||||
type: boolean
|
||||
enabled:
|
||||
@@ -8179,52 +8135,6 @@ paths:
|
||||
summary: 添加 Cloudflare 指向成员
|
||||
tags:
|
||||
- openflare-cloudflare
|
||||
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: 原分组 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 成员 ID
|
||||
in: path
|
||||
name: memberId
|
||||
required: true
|
||||
type: integer
|
||||
- description: 目标分组参数
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/cloudflare.MemberMoveInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/cloudflare.MemberItem'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 移动 Cloudflare 指向成员到其他分组
|
||||
tags:
|
||||
- openflare-cloudflare
|
||||
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove:
|
||||
post:
|
||||
parameters:
|
||||
@@ -8318,110 +8228,6 @@ paths:
|
||||
summary: 更新 Cloudflare 指向成员
|
||||
tags:
|
||||
- openflare-cloudflare
|
||||
/api/v1/d/cloudflare/groups/{id}/members/batch-move:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: 原分组 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 批量移动参数
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/cloudflare.MemberBatchMoveInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 批量移动 Cloudflare 指向成员
|
||||
tags:
|
||||
- openflare-cloudflare
|
||||
/api/v1/d/cloudflare/groups/{id}/members/batch-proxy:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: 分组 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 批量开启橙云参数
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/cloudflare.MemberBatchProxyInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 批量开启 Cloudflare 橙云
|
||||
tags:
|
||||
- openflare-cloudflare
|
||||
/api/v1/d/cloudflare/groups/{id}/members/batch-remove:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: 分组 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 批量移出参数
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/cloudflare.MemberBatchRemoveInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 批量移出 Cloudflare 指向成员
|
||||
tags:
|
||||
- openflare-cloudflare
|
||||
/api/v1/d/cloudflare/groups/{id}/sync:
|
||||
post:
|
||||
parameters:
|
||||
@@ -13063,7 +12869,7 @@ paths:
|
||||
$ref: '#/definitions/oauth.BasicUserInfo'
|
||||
type: object
|
||||
"400":
|
||||
description: 用户名或密码错误
|
||||
description: 用户名或密码错误、帐号已禁用等
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"500":
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type {
|
||||
CloudflareGroup,
|
||||
CloudflareGroupPayload,
|
||||
@@ -51,15 +50,7 @@ export function GroupDialog({
|
||||
() => nodes.filter((node) => node.node_type === 'edge_node'),
|
||||
[nodes],
|
||||
);
|
||||
const recordContentPlaceholders = {
|
||||
CNAME: t('recordContentPlaceholder.CNAME'),
|
||||
A: t('recordContentPlaceholder.A'),
|
||||
AAAA: t('recordContentPlaceholder.AAAA'),
|
||||
};
|
||||
const [name, setName] = useState('');
|
||||
const [targetMode, setTargetMode] = useState<'node' | 'custom'>('node');
|
||||
const [recordType, setRecordType] = useState<'CNAME' | 'A' | 'AAAA'>('A');
|
||||
const [recordContent, setRecordContent] = useState('');
|
||||
const [primaryNodeID, setPrimaryNodeID] = useState('');
|
||||
const [backupNodeID, setBackupNodeID] = useState('none');
|
||||
const [defaultProxied, setDefaultProxied] = useState(true);
|
||||
@@ -68,19 +59,8 @@ export function GroupDialog({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(group?.name ?? '');
|
||||
setTargetMode(group?.target_mode === 'custom' ? 'custom' : 'node');
|
||||
setRecordType(group?.record_type ?? 'A');
|
||||
setRecordContent(group?.record_content ?? '');
|
||||
setPrimaryNodeID(
|
||||
group?.target_mode !== 'custom' && group
|
||||
? String(group.primary_node.id)
|
||||
: '',
|
||||
);
|
||||
setBackupNodeID(
|
||||
group?.target_mode !== 'custom' && group?.backup_node
|
||||
? String(group.backup_node.id)
|
||||
: 'none',
|
||||
);
|
||||
setPrimaryNodeID(group ? String(group.primary_node.id) : '');
|
||||
setBackupNodeID(group?.backup_node ? String(group.backup_node.id) : 'none');
|
||||
setDefaultProxied(group?.default_proxied ?? true);
|
||||
setEnabled(group?.enabled ?? true);
|
||||
}, [group, open]);
|
||||
@@ -102,113 +82,46 @@ export function GroupDialog({
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel id='cf-target-mode-label'>{t('targetMode')}</FieldLabel>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
value={targetMode}
|
||||
aria-labelledby='cf-target-mode-label'
|
||||
className='grid w-full grid-cols-2'
|
||||
onValueChange={(value) => {
|
||||
if (value === 'node' || value === 'custom') {
|
||||
setTargetMode(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value='node' className='w-full'>
|
||||
{t('bindNode')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='custom' className='w-full'>
|
||||
{t('customTarget')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
|
||||
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
|
||||
<SelectTrigger id='cf-primary-node' className='w-full'>
|
||||
<SelectValue placeholder={t('primaryPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{edgeNodes.map((node) => (
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
value={String(node.id)}
|
||||
disabled={!node.ip}
|
||||
>
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
|
||||
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
|
||||
<SelectTrigger id='cf-backup-node' className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='none'>{t('none')}</SelectItem>
|
||||
{edgeNodes
|
||||
.filter((node) => String(node.id) !== primaryNodeID)
|
||||
.map((node) => (
|
||||
<SelectItem key={node.id} value={String(node.id)}>
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{targetMode === 'node' ? (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-primary-node'>
|
||||
{t('primary')}
|
||||
</FieldLabel>
|
||||
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
|
||||
<SelectTrigger id='cf-primary-node' className='w-full'>
|
||||
<SelectValue placeholder={t('primaryPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{edgeNodes.map((node) => (
|
||||
<SelectItem
|
||||
key={node.id}
|
||||
value={String(node.id)}
|
||||
disabled={!node.ip}
|
||||
>
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
|
||||
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
|
||||
<SelectTrigger id='cf-backup-node' className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='none'>{t('none')}</SelectItem>
|
||||
{edgeNodes
|
||||
.filter((node) => String(node.id) !== primaryNodeID)
|
||||
.map((node) => (
|
||||
<SelectItem key={node.id} value={String(node.id)}>
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-record-type'>
|
||||
{t('recordType')}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={recordType}
|
||||
onValueChange={(value: 'CNAME' | 'A' | 'AAAA') =>
|
||||
setRecordType(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id='cf-record-type' className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='CNAME'>CNAME</SelectItem>
|
||||
<SelectItem value='A'>A</SelectItem>
|
||||
<SelectItem value='AAAA'>AAAA</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-record-content'>
|
||||
{t('recordContent')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='cf-record-content'
|
||||
value={recordContent}
|
||||
placeholder={recordContentPlaceholders[recordType]}
|
||||
onChange={(event) => setRecordContent(event.target.value)}
|
||||
/>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{t('recordContentHint')}
|
||||
</p>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field orientation='horizontal'>
|
||||
<FieldLabel htmlFor='cf-default-proxied'>
|
||||
{t('defaultProxied')}
|
||||
@@ -233,23 +146,13 @@ export function GroupDialog({
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
pending ||
|
||||
!name.trim() ||
|
||||
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
|
||||
}
|
||||
disabled={pending || !name.trim() || !primaryNodeID}
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
target_mode: targetMode,
|
||||
record_type: recordType,
|
||||
record_content: recordContent.trim(),
|
||||
primary_node_id:
|
||||
targetMode === 'node' ? Number(primaryNodeID) : 0,
|
||||
primary_node_id: Number(primaryNodeID),
|
||||
backup_node_id:
|
||||
targetMode === 'node' && backupNodeID !== 'none'
|
||||
? Number(backupNodeID)
|
||||
: null,
|
||||
backupNodeID === 'none' ? null : Number(backupNodeID),
|
||||
default_proxied: defaultProxied,
|
||||
enabled,
|
||||
})
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type {
|
||||
CloudflareGroup,
|
||||
CloudflareMember,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
export interface MemberMoveDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
members: CloudflareMember[];
|
||||
groups: CloudflareGroup[];
|
||||
currentGroupId: number;
|
||||
pending: boolean;
|
||||
onSubmit: (targetGroupId: number) => void;
|
||||
}
|
||||
|
||||
export function MemberMoveDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
members,
|
||||
groups,
|
||||
currentGroupId,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: MemberMoveDialogProps) {
|
||||
const t = useTranslations('cloudflare');
|
||||
const tCommon = useTranslations('common');
|
||||
const [targetGroupId, setTargetGroupId] = useState('');
|
||||
|
||||
const availableGroups = useMemo(
|
||||
() => groups.filter((g) => g.id !== currentGroupId),
|
||||
[groups, currentGroupId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTargetGroupId('');
|
||||
}, [open]);
|
||||
|
||||
const title =
|
||||
members.length === 1
|
||||
? t('moveDialog.titleSingle', { domain: members[0]?.domain ?? '' })
|
||||
: t('moveDialog.titleBatch', { count: members.length });
|
||||
|
||||
const description =
|
||||
members.length > 0
|
||||
? members.map((m) => m.domain).join(', ')
|
||||
: t('moveDialog.targetGroupLabel');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!pending) {
|
||||
onOpenChange(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
onPointerDownOutside={(e) => {
|
||||
if (pending) e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (pending) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className='line-clamp-2'>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
{availableGroups.length === 0 ? (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{t('moveDialog.noOtherGroups')}
|
||||
</p>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-move-target-group'>
|
||||
{t('moveDialog.targetGroupLabel')}
|
||||
</FieldLabel>
|
||||
<Select value={targetGroupId} onValueChange={setTargetGroupId}>
|
||||
<SelectTrigger id='cf-move-target-group' className='w-full'>
|
||||
<SelectValue
|
||||
placeholder={t('moveDialog.targetGroupPlaceholder')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{availableGroups.map((group) => (
|
||||
<SelectItem key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
{group.primary_node?.ip
|
||||
? ` · ${group.primary_node.ip}`
|
||||
: ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending || !targetGroupId || availableGroups.length === 0}
|
||||
onClick={() => onSubmit(Number(targetGroupId))}
|
||||
>
|
||||
{pending ? t('moveDialog.moving') : t('moveDialog.submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRightLeft,
|
||||
Cloud,
|
||||
CloudCog,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -14,20 +12,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -37,7 +25,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
@@ -54,38 +41,18 @@ import {
|
||||
cloudflareQueryKey,
|
||||
NodeService,
|
||||
type CloudflareGroupPayload,
|
||||
type CloudflareMember,
|
||||
} from '@/lib/services/openflare';
|
||||
import { getErrorMessage } from '../../../websites/components/website-utils';
|
||||
import { GroupDialog } from '../../components/group-dialog';
|
||||
import { MemberAddDialog } from '../../components/member-add-dialog';
|
||||
import { MemberMoveDialog } from '../../components/member-move-dialog';
|
||||
|
||||
function getGroupIdFromPathname(pathname: string | null): number {
|
||||
const match = pathname?.match(/^\/cloudflare\/groups\/([^/]+)$/);
|
||||
return Number(match?.[1]);
|
||||
}
|
||||
|
||||
export function CloudflareGroupDetailPageClient() {
|
||||
const t = useTranslations('cloudflare');
|
||||
const tCommon = useTranslations('common');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const pathname = usePathname();
|
||||
const groupID = useMemo(() => getGroupIdFromPathname(pathname), [pathname]);
|
||||
const params = useParams<{ id: string }>();
|
||||
const groupID = Number(params.id);
|
||||
const queryClient = useQueryClient();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [selectedMemberIDs, setSelectedMemberIDs] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||
const [movingMembers, setMovingMembers] = useState<CloudflareMember[]>([]);
|
||||
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: [...cloudflareQueryKey, 'groups', groupID],
|
||||
queryFn: () => CloudflareService.getGroup(groupID),
|
||||
@@ -100,10 +67,6 @@ export function CloudflareGroupDetailPageClient() {
|
||||
queryKey: ['openflare', 'nodes'],
|
||||
queryFn: () => NodeService.listNodes(),
|
||||
});
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: [...cloudflareQueryKey, 'groups'],
|
||||
queryFn: () => CloudflareService.listGroups(),
|
||||
});
|
||||
const invalidate = async () =>
|
||||
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
||||
|
||||
@@ -177,92 +140,14 @@ export function CloudflareGroupDetailPageClient() {
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (memberID: number) =>
|
||||
CloudflareService.removeMember(groupID, memberID),
|
||||
onSuccess: async (_, memberID) => {
|
||||
onSuccess: async () => {
|
||||
toast.success(t('memberDeleted'));
|
||||
setSelectedMemberIDs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(memberID);
|
||||
return next;
|
||||
});
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
const moveMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
targetGroupId,
|
||||
membersToMove,
|
||||
}: {
|
||||
targetGroupId: number;
|
||||
membersToMove: CloudflareMember[];
|
||||
}) => {
|
||||
if (membersToMove.length === 1) {
|
||||
return CloudflareService.moveMember(
|
||||
groupID,
|
||||
membersToMove[0].id,
|
||||
targetGroupId,
|
||||
);
|
||||
}
|
||||
return CloudflareService.batchMoveMembers(
|
||||
groupID,
|
||||
membersToMove.map((m) => m.id),
|
||||
targetGroupId,
|
||||
);
|
||||
},
|
||||
onSuccess: async (_, variables) => {
|
||||
if (variables.membersToMove.length === 1) {
|
||||
toast.success(t('memberMoved'));
|
||||
} else {
|
||||
toast.success(
|
||||
t('batchMoved', { count: variables.membersToMove.length }),
|
||||
);
|
||||
}
|
||||
setMoveDialogOpen(false);
|
||||
setSelectedMemberIDs((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const m of variables.membersToMove) {
|
||||
next.delete(m.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
const batchRemoveMutation = useMutation({
|
||||
mutationFn: (memberIds: number[]) =>
|
||||
CloudflareService.batchRemoveMembers(groupID, memberIds),
|
||||
onSuccess: async (_, memberIds) => {
|
||||
toast.success(t('batchRemoved', { count: memberIds.length }));
|
||||
setBatchRemoveOpen(false);
|
||||
setSelectedMemberIDs(new Set());
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
const batchEnableProxyMutation = useMutation({
|
||||
mutationFn: (memberIds: number[]) =>
|
||||
CloudflareService.batchEnableProxy(groupID, memberIds),
|
||||
onSuccess: async (_, memberIds) => {
|
||||
toast.success(t('batchProxyEnabled', { count: memberIds.length }));
|
||||
setSelectedMemberIDs(new Set());
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
const handleSingleMove = (member: CloudflareMember) => {
|
||||
setMovingMembers([member]);
|
||||
setMoveDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleBatchMove = (selectedList: CloudflareMember[]) => {
|
||||
if (selectedList.length === 0) return;
|
||||
setMovingMembers(selectedList);
|
||||
setMoveDialogOpen(true);
|
||||
};
|
||||
|
||||
if (!mounted || detailQuery.isLoading)
|
||||
if (detailQuery.isLoading)
|
||||
return (
|
||||
<div className='w-full py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={Cloud} description={t('loadingDetail')} />
|
||||
@@ -279,12 +164,6 @@ export function CloudflareGroupDetailPageClient() {
|
||||
);
|
||||
const { group, members } = detailQuery.data;
|
||||
|
||||
const selectedMembers = members.filter((m) => selectedMemberIDs.has(m.id));
|
||||
const allSelected =
|
||||
members.length > 0 && selectedMembers.length === members.length;
|
||||
const isIndeterminate =
|
||||
selectedMembers.length > 0 && selectedMembers.length < members.length;
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
@@ -335,92 +214,31 @@ export function CloudflareGroupDetailPageClient() {
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
|
||||
<CardDescription>
|
||||
{group.target_mode === 'custom'
|
||||
? t('customTargetSummary', {
|
||||
type: group.record_type,
|
||||
content: group.record_content,
|
||||
})
|
||||
: t('activeNode', {
|
||||
name: group.active_node.name,
|
||||
ip: group.active_node.ip,
|
||||
})}
|
||||
{t('activeNode', {
|
||||
name: group.active_node.name,
|
||||
ip: group.active_node.ip,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-wrap gap-2'>
|
||||
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
||||
{group.enabled ? t('syncEnabled') : t('syncDisabled')}
|
||||
</Badge>
|
||||
{group.target_mode === 'node' ? (
|
||||
<>
|
||||
<Badge variant='outline'>
|
||||
{t('primaryNode', { name: group.primary_node.name })}
|
||||
</Badge>
|
||||
<Badge variant='outline'>
|
||||
{t('backupNode', {
|
||||
name: group.backup_node?.name ?? t('backupUnset'),
|
||||
})}
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
<Badge variant='outline'>
|
||||
{t(
|
||||
group.target_mode === 'custom'
|
||||
? 'customTargetMode'
|
||||
: 'nodeTargetMode',
|
||||
)}
|
||||
{t('primaryNode', { name: group.primary_node.name })}
|
||||
</Badge>
|
||||
<Badge variant='outline'>
|
||||
{t('backupNode', {
|
||||
name: group.backup_node?.name ?? t('backupUnset'),
|
||||
})}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>{t('members')}</CardTitle>
|
||||
<CardDescription>{t('membersDesc')}</CardDescription>
|
||||
</div>
|
||||
{selectedMembers.length > 0 && (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant='secondary' className='text-xs'>
|
||||
{t('selectedCount', { count: selectedMembers.length })}
|
||||
</Badge>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
batchEnableProxyMutation.mutate(
|
||||
selectedMembers.map((member) => member.id),
|
||||
)
|
||||
}
|
||||
disabled={batchEnableProxyMutation.isPending}
|
||||
>
|
||||
<CloudCog data-icon='inline-start' />
|
||||
{t('batchEnableProxy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleBatchMove(selectedMembers)}
|
||||
>
|
||||
<ArrowRightLeft data-icon='inline-start' />
|
||||
{t('batchMove')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setBatchRemoveOpen(true)}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
{t('batchRemove')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setSelectedMemberIDs(new Set())}
|
||||
>
|
||||
{t('clearSelection')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>{t('members')}</CardTitle>
|
||||
<CardDescription>{t('membersDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{members.length === 0 ? (
|
||||
@@ -431,27 +249,6 @@ export function CloudflareGroupDetailPageClient() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-12'>
|
||||
<Checkbox
|
||||
checked={
|
||||
allSelected
|
||||
? true
|
||||
: isIndeterminate
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedMemberIDs(
|
||||
new Set(members.map((m) => m.id)),
|
||||
);
|
||||
} else {
|
||||
setSelectedMemberIDs(new Set());
|
||||
}
|
||||
}}
|
||||
aria-label='Select all'
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('columns.domain')}</TableHead>
|
||||
<TableHead>{t('columns.desiredIp')}</TableHead>
|
||||
<TableHead>{t('columns.status')}</TableHead>
|
||||
@@ -463,29 +260,7 @@ export function CloudflareGroupDetailPageClient() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((member) => (
|
||||
<TableRow
|
||||
key={member.id}
|
||||
data-state={
|
||||
selectedMemberIDs.has(member.id) ? 'selected' : undefined
|
||||
}
|
||||
>
|
||||
<TableCell className='w-12'>
|
||||
<Checkbox
|
||||
checked={selectedMemberIDs.has(member.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedMemberIDs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
next.add(member.id);
|
||||
} else {
|
||||
next.delete(member.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-label={`Select ${member.domain}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableRow key={member.id}>
|
||||
<TableCell>
|
||||
<div className='font-medium'>{member.domain}</div>
|
||||
{member.last_error ? (
|
||||
@@ -525,30 +300,19 @@ export function CloudflareGroupDetailPageClient() {
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
title={t('move')}
|
||||
aria-label={t('move')}
|
||||
onClick={() => handleSingleMove(member)}
|
||||
>
|
||||
<ArrowRightLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
title={t('sync')}
|
||||
aria-label={t('sync')}
|
||||
size='sm'
|
||||
onClick={() => syncMutation.mutate(member.id)}
|
||||
>
|
||||
<RefreshCw />
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
{t('sync')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
title={t('remove')}
|
||||
aria-label={t('remove')}
|
||||
size='sm'
|
||||
onClick={() => removeMutation.mutate(member.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
<Trash2 data-icon='inline-start' />
|
||||
{t('remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -578,43 +342,6 @@ export function CloudflareGroupDetailPageClient() {
|
||||
addMutation.mutate({ domainIDs, proxied })
|
||||
}
|
||||
/>
|
||||
<MemberMoveDialog
|
||||
open={moveDialogOpen}
|
||||
onOpenChange={setMoveDialogOpen}
|
||||
members={movingMembers}
|
||||
groups={groupsQuery.data ?? []}
|
||||
currentGroupId={groupID}
|
||||
pending={moveMutation.isPending}
|
||||
onSubmit={(targetGroupId) =>
|
||||
moveMutation.mutate({
|
||||
targetGroupId,
|
||||
membersToMove: movingMembers,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<AlertDialog open={batchRemoveOpen} onOpenChange={setBatchRemoveOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('batchRemoveDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('batchRemoveDialog.desc', { count: selectedMembers.length })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={batchRemoveMutation.isPending}
|
||||
onClick={() =>
|
||||
batchRemoveMutation.mutate(selectedMembers.map((m) => m.id))
|
||||
}
|
||||
>
|
||||
{batchRemoveMutation.isPending
|
||||
? t('batchRemoveDialog.removing')
|
||||
: t('batchRemoveDialog.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
|
||||
import { CloudflareGroupDetailPageClient } from './page-client';
|
||||
|
||||
/** Placeholder for static export; real ids resolve client-side from the pathname. */
|
||||
/** Placeholder for static export; real ids resolve client-side via useParams. */
|
||||
export async function generateStaticParams() {
|
||||
return [{ id: '1' }];
|
||||
}
|
||||
|
||||
@@ -182,12 +182,7 @@ export default function CloudflarePage() {
|
||||
<div>
|
||||
<CardTitle className='text-base'>{group.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{group.target_mode === 'custom'
|
||||
? t('customTargetSummary', {
|
||||
type: group.record_type,
|
||||
content: group.record_content,
|
||||
})
|
||||
: `${group.active_node.name} · ${group.active_node.ip}`}
|
||||
{group.active_node.name} · {group.active_node.ip}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const defaultPerformanceFields: PerformanceFields = {
|
||||
openresty_proxy_read_timeout: '60',
|
||||
openresty_websocket_enabled: true,
|
||||
openresty_http3_enabled: true,
|
||||
openresty_proxy_request_buffering_enabled: true,
|
||||
openresty_proxy_request_buffering_enabled: false,
|
||||
openresty_proxy_buffering_enabled: true,
|
||||
openresty_proxy_buffers: '16 16k',
|
||||
openresty_proxy_buffer_size: '8k',
|
||||
@@ -133,7 +133,7 @@ export function mapOptionsToFields(
|
||||
),
|
||||
openresty_proxy_request_buffering_enabled: toBoolean(
|
||||
optionMap.openresty_proxy_request_buffering_enabled,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
openresty_proxy_buffering_enabled: toBoolean(
|
||||
optionMap.openresty_proxy_buffering_enabled,
|
||||
|
||||
@@ -345,13 +345,6 @@ export default function PerformancePage() {
|
||||
updateField('openresty_proxy_buffering_enabled', v)
|
||||
}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('proxyRequestBuffering')}
|
||||
checked={fields.openresty_proxy_request_buffering_enabled}
|
||||
onChange={(v) =>
|
||||
updateField('openresty_proxy_request_buffering_enabled', v)
|
||||
}
|
||||
/>
|
||||
<FieldInput
|
||||
label='proxy_buffers'
|
||||
value={fields.openresty_proxy_buffers}
|
||||
@@ -506,7 +499,7 @@ function ToggleRow({
|
||||
return (
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed px-3 py-2'>
|
||||
<Label className='text-xs'>{label}</Label>
|
||||
<Switch checked={checked} onCheckedChange={onChange} aria-label={label} />
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +380,6 @@ export function buildPayloadFromRoute(
|
||||
upstreams: (route.upstream_list ?? []).slice(1),
|
||||
enabled: route.enabled,
|
||||
enable_https: route.enable_https,
|
||||
enable_http2: route.enable_http2,
|
||||
redirect_http: route.redirect_http,
|
||||
limit_conn_per_server: route.limit_conn_per_server,
|
||||
limit_conn_per_ip: route.limit_conn_per_ip,
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { ProxyRouteItem } from '@/lib/services/openflare';
|
||||
import { NodeService, PagesService } from '@/lib/services/openflare';
|
||||
@@ -51,7 +50,6 @@ type ReverseProxyValues = {
|
||||
tunnel_target_protocol?: 'http' | 'https';
|
||||
pages_project_id?: string;
|
||||
custom_headers_text: string;
|
||||
enable_http2: boolean;
|
||||
};
|
||||
|
||||
interface ProxySectionProps {
|
||||
@@ -76,7 +74,6 @@ export function ProxySection({
|
||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||
pages_project_id: z.string().optional(),
|
||||
custom_headers_text: z.string(),
|
||||
enable_http2: z.boolean(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.upstream_type === 'direct') {
|
||||
@@ -177,7 +174,6 @@ export function ProxySection({
|
||||
? String(route.pages_project_id)
|
||||
: '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
enable_http2: route.enable_http2 ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -194,7 +190,6 @@ export function ProxySection({
|
||||
? String(route.pages_project_id)
|
||||
: '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
enable_http2: route.enable_http2 ?? true,
|
||||
});
|
||||
}, [form, route]);
|
||||
|
||||
@@ -272,7 +267,6 @@ export function ProxySection({
|
||||
values.upstream_type === 'pages' && values.pages_project_id
|
||||
? Number(values.pages_project_id)
|
||||
: null,
|
||||
enable_http2: values.enable_http2,
|
||||
},
|
||||
t('proxySaved'),
|
||||
);
|
||||
@@ -312,25 +306,6 @@ export function ProxySection({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='enable_http2'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
|
||||
<div className='space-y-1'>
|
||||
<FormLabel>{t('enableHttp2')}</FormLabel>
|
||||
<FormDescription>{t('enableHttp2Desc')}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{upstreamType === 'direct' ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Search, Trash2, X } from 'lucide-react';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -60,14 +59,6 @@ export function IPGroupViewDialog({
|
||||
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchKeyword('');
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const entries = useMemo(
|
||||
() =>
|
||||
@@ -77,13 +68,6 @@ export function IPGroupViewDialog({
|
||||
[group, t],
|
||||
);
|
||||
|
||||
const trimmedKeyword = searchKeyword.trim().toLowerCase();
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!trimmedKeyword) return entries;
|
||||
return entries.filter((e) => e.ip.toLowerCase().includes(trimmedKeyword));
|
||||
}, [entries, trimmedKeyword]);
|
||||
|
||||
const showAutomaticMeta = group?.type === 'automatic';
|
||||
|
||||
return (
|
||||
@@ -94,7 +78,6 @@ export function IPGroupViewDialog({
|
||||
onOpenChange(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setDeleteTarget(null);
|
||||
setSearchKeyword('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -107,16 +90,10 @@ export function IPGroupViewDialog({
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{group
|
||||
? trimmedKeyword
|
||||
? t('viewDialog.summaryFiltered', {
|
||||
type: t(`types.${group.type}`),
|
||||
count: entries.length,
|
||||
matched: filteredEntries.length,
|
||||
})
|
||||
: t('viewDialog.summary', {
|
||||
type: t(`types.${group.type}`),
|
||||
count: entries.length,
|
||||
})
|
||||
? t('viewDialog.summary', {
|
||||
type: t(`types.${group.type}`),
|
||||
count: entries.length,
|
||||
})
|
||||
: t('viewDialog.fallbackDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -145,95 +122,64 @@ export function IPGroupViewDialog({
|
||||
{t('viewDialog.subscriptionHint')}
|
||||
</p>
|
||||
) : null}
|
||||
<div className='relative'>
|
||||
<Search className='pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
placeholder={t('viewDialog.searchPlaceholder')}
|
||||
className='pl-8 pr-8'
|
||||
/>
|
||||
{searchKeyword ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='absolute right-1 top-1/2 size-7 -translate-y-1/2 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => setSearchKeyword('')}
|
||||
>
|
||||
<X className='size-3.5' />
|
||||
<span className='sr-only'>
|
||||
{t('viewDialog.clearSearch')}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{filteredEntries.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
description={t('viewDialog.noSearchResult')}
|
||||
/>
|
||||
) : (
|
||||
<div className='rounded-lg border border-dashed'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('viewDialog.ipAddress')}</TableHead>
|
||||
<div className='rounded-lg border border-dashed'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('viewDialog.ipAddress')}</TableHead>
|
||||
{showAutomaticMeta ? (
|
||||
<>
|
||||
<TableHead>{t('viewDialog.capturedAt')}</TableHead>
|
||||
<TableHead>{t('viewDialog.banRemaining')}</TableHead>
|
||||
</>
|
||||
) : null}
|
||||
<TableHead className='w-[80px] text-right'>
|
||||
{t('viewDialog.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.ip}>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
{entry.ip}
|
||||
</TableCell>
|
||||
{showAutomaticMeta ? (
|
||||
<>
|
||||
<TableHead>{t('viewDialog.capturedAt')}</TableHead>
|
||||
<TableHead>
|
||||
{t('viewDialog.banRemaining')}
|
||||
</TableHead>
|
||||
<TableCell className='text-sm text-muted-foreground'>
|
||||
{entry.capturedAt
|
||||
? formatDateTime(entry.capturedAt)
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className='text-sm'>
|
||||
{entry.banRemaining ?? '—'}
|
||||
</TableCell>
|
||||
</>
|
||||
) : null}
|
||||
<TableHead className='w-[80px] text-right'>
|
||||
{t('viewDialog.actions')}
|
||||
</TableHead>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8 text-destructive hover:text-destructive'
|
||||
disabled={removingIp === entry.ip}
|
||||
onClick={() => setDeleteTarget(entry)}
|
||||
>
|
||||
{removingIp === entry.ip ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Trash2 className='size-4' />
|
||||
)}
|
||||
<span className='sr-only'>
|
||||
{t('viewDialog.deleteIp', { ip: entry.ip })}
|
||||
</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEntries.map((entry) => (
|
||||
<TableRow key={entry.ip}>
|
||||
<TableCell className='font-mono text-sm'>
|
||||
{entry.ip}
|
||||
</TableCell>
|
||||
{showAutomaticMeta ? (
|
||||
<>
|
||||
<TableCell className='text-sm text-muted-foreground'>
|
||||
{entry.capturedAt
|
||||
? formatDateTime(entry.capturedAt)
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className='text-sm'>
|
||||
{entry.banRemaining ?? '—'}
|
||||
</TableCell>
|
||||
</>
|
||||
) : null}
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8 text-destructive hover:text-destructive'
|
||||
disabled={removingIp === entry.ip}
|
||||
onClick={() => setDeleteTarget(entry)}
|
||||
>
|
||||
{removingIp === entry.ip ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Trash2 className='size-4' />
|
||||
)}
|
||||
<span className='sr-only'>
|
||||
{t('viewDialog.deleteIp', { ip: entry.ip })}
|
||||
</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -102,41 +102,4 @@ export class CloudflareService extends OpenFlareBaseService {
|
||||
`/groups/${groupId}/members/${memberId}/sync`,
|
||||
);
|
||||
}
|
||||
|
||||
static moveMember(
|
||||
groupId: number,
|
||||
memberId: number,
|
||||
targetGroupId: number,
|
||||
): Promise<CloudflareMember> {
|
||||
return this.post<CloudflareMember>(
|
||||
`/groups/${groupId}/members/${memberId}/move`,
|
||||
{ target_group_id: targetGroupId },
|
||||
);
|
||||
}
|
||||
|
||||
static batchMoveMembers(
|
||||
groupId: number,
|
||||
memberIds: number[],
|
||||
targetGroupId: number,
|
||||
): Promise<void> {
|
||||
return this.post<void>(`/groups/${groupId}/members/batch-move`, {
|
||||
member_ids: memberIds,
|
||||
target_group_id: targetGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
static batchRemoveMembers(
|
||||
groupId: number,
|
||||
memberIds: number[],
|
||||
): Promise<void> {
|
||||
return this.post<void>(`/groups/${groupId}/members/batch-remove`, {
|
||||
member_ids: memberIds,
|
||||
});
|
||||
}
|
||||
|
||||
static batchEnableProxy(groupId: number, memberIds: number[]): Promise<void> {
|
||||
return this.post<void>(`/groups/${groupId}/members/batch-proxy`, {
|
||||
member_ids: memberIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +234,6 @@ export interface ProxyRouteItem {
|
||||
upstream_list: string[];
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
enable_http2: boolean;
|
||||
redirect_http: boolean;
|
||||
limit_conn_per_server: number;
|
||||
limit_conn_per_ip: number;
|
||||
@@ -272,7 +271,6 @@ export interface ProxyRouteMutationPayload {
|
||||
upstreams: string[];
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
enable_http2?: boolean;
|
||||
redirect_http: boolean;
|
||||
limit_conn_per_server?: number;
|
||||
limit_conn_per_ip?: number;
|
||||
@@ -1369,9 +1367,6 @@ export interface CloudflareNodeOption {
|
||||
export interface CloudflareGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
target_mode: 'node' | 'custom';
|
||||
record_type: 'CNAME' | 'A' | 'AAAA';
|
||||
record_content: string;
|
||||
primary_node: CloudflareNodeOption;
|
||||
backup_node: CloudflareNodeOption | null;
|
||||
active_node: CloudflareNodeOption;
|
||||
@@ -1384,9 +1379,6 @@ export interface CloudflareGroup {
|
||||
|
||||
export interface CloudflareGroupPayload {
|
||||
name: string;
|
||||
target_mode: 'node' | 'custom';
|
||||
record_type: 'CNAME' | 'A' | 'AAAA';
|
||||
record_content: string;
|
||||
primary_node_id: number;
|
||||
backup_node_id: number | null;
|
||||
default_proxied: boolean;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"cloudflare": {
|
||||
"move": "Move",
|
||||
"batchMove": "Batch Move",
|
||||
"batchRemove": "Batch Delete",
|
||||
"selectedCount": "{count} selected",
|
||||
"clearSelection": "Clear selection",
|
||||
"moveDialog": {
|
||||
"titleSingle": "Move domain \"{domain}\"",
|
||||
"titleBatch": "Batch move domains ({count} items)",
|
||||
"targetGroupLabel": "Target pointing group",
|
||||
"targetGroupPlaceholder": "Select target group",
|
||||
"noOtherGroups": "No other pointing groups available. Please create one first.",
|
||||
"submit": "Confirm move",
|
||||
"moving": "Moving..."
|
||||
},
|
||||
"batchRemoveDialog": {
|
||||
"title": "Confirm batch domain deletion",
|
||||
"desc": "Are you sure you want to remove {count} selected domains from the current group? This will also clean up DNS records on Cloudflare.",
|
||||
"confirm": "Confirm delete",
|
||||
"removing": "Deleting..."
|
||||
},
|
||||
"memberMoved": "Domain moved successfully",
|
||||
"batchMoved": "Successfully moved {count} domains",
|
||||
"batchRemoved": "Successfully deleted {count} domains"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"cloudflare": {
|
||||
"move": "移动",
|
||||
"batchMove": "批量移动",
|
||||
"batchRemove": "批量删除",
|
||||
"selectedCount": "已选择 {count} 项",
|
||||
"clearSelection": "取消选择",
|
||||
"moveDialog": {
|
||||
"titleSingle": "移动域名「{domain}」",
|
||||
"titleBatch": "批量移动域名(共 {count} 项)",
|
||||
"targetGroupLabel": "目标指向分组",
|
||||
"targetGroupPlaceholder": "请选择目标分组",
|
||||
"noOtherGroups": "没有其他可用的指向分组,请先创建分组",
|
||||
"submit": "确认移动",
|
||||
"moving": "移动中..."
|
||||
},
|
||||
"batchRemoveDialog": {
|
||||
"title": "确认批量删除域名",
|
||||
"desc": "确认从当前分组移出已选中的 {count} 个域名吗?这将同时清理 Cloudflare 上的解析记录。",
|
||||
"confirm": "确认删除",
|
||||
"removing": "删除中..."
|
||||
},
|
||||
"memberMoved": "域名移动成功",
|
||||
"batchMoved": "成功移动 {count} 个域名",
|
||||
"batchRemoved": "成功删除 {count} 个域名"
|
||||
}
|
||||
}
|
||||
@@ -509,8 +509,7 @@
|
||||
"runtimeTitle": "Connections and events",
|
||||
"runtimeDesc": "Workers, timeouts, and request body size",
|
||||
"proxyTitle": "Proxy buffers and timeouts",
|
||||
"proxyDesc": "Upstream connections, request/response buffering, and WebSocket/HTTP3 switches",
|
||||
"proxyRequestBuffering": "Request body buffering (turn off for streaming uploads)",
|
||||
"proxyDesc": "Upstream connections, buffers, and WebSocket/HTTP3 switches",
|
||||
"gzipTitle": "Compression",
|
||||
"cacheTitle": "Cache",
|
||||
"cacheDesc": "Single-node reverse-proxy cache",
|
||||
|
||||
@@ -509,8 +509,7 @@
|
||||
"runtimeTitle": "连接与事件",
|
||||
"runtimeDesc": "worker、超时、请求体大小等运行参数",
|
||||
"proxyTitle": "反代缓冲与超时",
|
||||
"proxyDesc": "upstream 连接、请求与响应缓冲,以及 WebSocket/HTTP3 开关",
|
||||
"proxyRequestBuffering": "请求体缓冲(流式上传时关闭)",
|
||||
"proxyDesc": "upstream 连接、缓冲与 WebSocket/HTTP3 开关",
|
||||
"gzipTitle": "压缩",
|
||||
"cacheTitle": "缓存",
|
||||
"cacheDesc": "单节点反代缓存优化场景",
|
||||
|
||||
@@ -257,10 +257,6 @@
|
||||
"titleNamed": "View {name}",
|
||||
"title": "View IP group",
|
||||
"summary": "{type} · {count} IPs",
|
||||
"summaryFiltered": "{type} · {count} IPs ({matched} matched)",
|
||||
"searchPlaceholder": "Search IP addresses...",
|
||||
"clearSearch": "Clear search",
|
||||
"noSearchResult": "No matching IP addresses found.",
|
||||
"fallbackDesc": "View IPs in this group and remove entries you no longer need.",
|
||||
"loading": "Loading IP list...",
|
||||
"noGroup": "No IP group selected.",
|
||||
@@ -772,10 +768,10 @@
|
||||
"notReadyDesc": "Import an existing Cloudflare DNS account, or enter a standalone API token and test the connection.",
|
||||
"configure": "Configure connection",
|
||||
"groupsTitle": "Pointing groups",
|
||||
"groupsDesc": "Manage Cloudflare DNS targets and domain members.",
|
||||
"groupsDesc": "Manage the nodes and domain members behind Cloudflare A records.",
|
||||
"loadingGroups": "Loading pointing groups...",
|
||||
"emptyTitle": "No pointing groups",
|
||||
"emptyDesc": "Create a group to bind domain members to an edge node or a custom DNS target.",
|
||||
"emptyDesc": "After creating a group, you can sync domain members to selected edge nodes.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"memberSummary": "{count} members · new members default orange-cloud {proxy}",
|
||||
@@ -788,7 +784,7 @@
|
||||
"syncQueued": "Group sync queued",
|
||||
"deleted": "Pointing group and remote records deleted",
|
||||
"deleteTitle": "Delete pointing group",
|
||||
"deleteDesc": "This deletes all members of {name} and the remote DNS records managed by this module. This cannot be undone.",
|
||||
"deleteDesc": "This deletes all members of {name} and the remote A records managed by this module. This cannot be undone.",
|
||||
"confirmDelete": "Delete",
|
||||
"loadingDetail": "Loading group details...",
|
||||
"groupUpdated": "Group settings updated",
|
||||
@@ -798,18 +794,13 @@
|
||||
"memberPartial": "Partial success: {succeeded} added, {failed} failed",
|
||||
"proxyUpdated": "Orange-cloud setting updated and queued for sync",
|
||||
"memberSyncQueued": "Member sync queued",
|
||||
"memberDeleted": "Member and remote DNS record deleted",
|
||||
"batchEnableProxy": "Enable orange cloud",
|
||||
"batchProxyEnabled": "Enabled orange cloud for {count} members and queued sync",
|
||||
"memberDeleted": "Member and remote A record deleted",
|
||||
"back": "Back to list",
|
||||
"refresh": "Refresh",
|
||||
"edit": "Edit",
|
||||
"addDomain": "Add domain",
|
||||
"currentPoint": "Current target",
|
||||
"activeNode": "Active node {name} · {ip}",
|
||||
"customTargetSummary": "Custom target {type} · {content}",
|
||||
"nodeTargetMode": "Node binding",
|
||||
"customTargetMode": "Custom record",
|
||||
"syncEnabled": "Sync enabled",
|
||||
"syncDisabled": "Sync disabled",
|
||||
"primaryNode": "Primary {name}",
|
||||
@@ -820,7 +811,7 @@
|
||||
"noMembers": "No domain members yet.",
|
||||
"columns": {
|
||||
"domain": "Domain",
|
||||
"desiredIp": "Desired value",
|
||||
"desiredIp": "Desired IP",
|
||||
"status": "Status",
|
||||
"proxied": "Orange cloud",
|
||||
"actions": "Actions"
|
||||
@@ -830,19 +821,8 @@
|
||||
"groupDialog": {
|
||||
"editTitle": "Edit pointing group",
|
||||
"createTitle": "New pointing group",
|
||||
"description": "Bind members to an edge node or assign a custom DNS record target.",
|
||||
"description": "Phase 1 uses the primary node as the active node. Backup nodes are saved only and do not fail over automatically.",
|
||||
"name": "Group name",
|
||||
"targetMode": "Target mode",
|
||||
"bindNode": "Bind node",
|
||||
"customTarget": "Custom",
|
||||
"recordType": "Record type",
|
||||
"recordContent": "Record value",
|
||||
"recordContentHint": "Enter an IPv4 for A, IPv6 for AAAA, or a target hostname for CNAME.",
|
||||
"recordContentPlaceholder": {
|
||||
"CNAME": "e.g. origin.example.com",
|
||||
"A": "e.g. 203.0.113.10",
|
||||
"AAAA": "e.g. 2001:db8::10"
|
||||
},
|
||||
"primary": "Primary node",
|
||||
"primaryPlaceholder": "Select an edge node with IPv4",
|
||||
"noIp": "No IP configured",
|
||||
@@ -854,7 +834,7 @@
|
||||
},
|
||||
"memberDialog": {
|
||||
"title": "Add domain members",
|
||||
"description": "Search and select in bulk. Joining creates or takes over the unique same-name DNS record used by this group.",
|
||||
"description": "Search and select in bulk. Joining creates or takes over the unique same-name A record. Multiple same-name A records are rejected.",
|
||||
"zoneDomain": "Zone domain",
|
||||
"searchPlaceholder": "Search domains or zones…",
|
||||
"selected": "{count} selected",
|
||||
|
||||
@@ -257,10 +257,6 @@
|
||||
"titleNamed": "查看 {name}",
|
||||
"title": "查看 IP 组",
|
||||
"summary": "{type} · 共 {count} 条 IP",
|
||||
"summaryFiltered": "{type} · 共 {count} 条 IP(匹配 {matched} 条)",
|
||||
"searchPlaceholder": "搜索 IP 地址...",
|
||||
"clearSearch": "清空搜索",
|
||||
"noSearchResult": "未找到匹配的 IP 地址。",
|
||||
"fallbackDesc": "查看当前 IP 组中的 IP 列表,并可移除不需要的条目。",
|
||||
"loading": "加载 IP 列表...",
|
||||
"noGroup": "未选择 IP 组。",
|
||||
@@ -772,10 +768,10 @@
|
||||
"notReadyDesc": "请先导入现有 Cloudflare DNS 账号,或录入独立 API Token 并完成连接测试。",
|
||||
"configure": "配置连接",
|
||||
"groupsTitle": "指向分组",
|
||||
"groupsDesc": "管理 Cloudflare DNS 记录的指向方式与域名成员。",
|
||||
"groupsDesc": "管理 Cloudflare A 记录对应的节点与域名成员。",
|
||||
"loadingGroups": "加载指向分组中...",
|
||||
"emptyTitle": "暂无指向分组",
|
||||
"emptyDesc": "创建分组后,可将域名成员绑定到边缘节点,或同步到自定义 DNS 目标。",
|
||||
"emptyDesc": "创建分组后,可将域名成员同步到指定的边缘节点。",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已停用",
|
||||
"memberSummary": "成员 {count} 个 · 新成员默认{proxy}橙云",
|
||||
@@ -788,7 +784,7 @@
|
||||
"syncQueued": "分组同步任务已入队",
|
||||
"deleted": "指向分组及远端记录已删除",
|
||||
"deleteTitle": "删除指向分组",
|
||||
"deleteDesc": "将删除 {name} 的全部成员及本模块管理的远端 DNS 记录。此操作不可撤销。",
|
||||
"deleteDesc": "将删除 {name} 的全部成员及本模块管理的远端 A 记录。此操作不可撤销。",
|
||||
"confirmDelete": "确认删除",
|
||||
"loadingDetail": "加载分组详情中...",
|
||||
"groupUpdated": "分组配置已更新",
|
||||
@@ -798,18 +794,13 @@
|
||||
"memberPartial": "部分成功:{succeeded} 个已加入,{failed} 个失败",
|
||||
"proxyUpdated": "橙云设置已更新并排队同步",
|
||||
"memberSyncQueued": "成员同步任务已入队",
|
||||
"memberDeleted": "成员及远端 DNS 记录已删除",
|
||||
"batchEnableProxy": "批量开启橙云",
|
||||
"batchProxyEnabled": "已为 {count} 个成员开启橙云并排队同步",
|
||||
"memberDeleted": "成员及远端 A 记录已删除",
|
||||
"back": "返回列表",
|
||||
"refresh": "刷新",
|
||||
"edit": "编辑",
|
||||
"addDomain": "添加域名",
|
||||
"currentPoint": "当前指向",
|
||||
"activeNode": "生效节点 {name} · {ip}",
|
||||
"customTargetSummary": "自定义目标 {type} · {content}",
|
||||
"nodeTargetMode": "绑定节点",
|
||||
"customTargetMode": "自定义记录",
|
||||
"syncEnabled": "同步已启用",
|
||||
"syncDisabled": "同步已停用",
|
||||
"primaryNode": "主节点 {name}",
|
||||
@@ -820,7 +811,7 @@
|
||||
"noMembers": "暂无域名成员。",
|
||||
"columns": {
|
||||
"domain": "域名",
|
||||
"desiredIp": "期望值",
|
||||
"desiredIp": "期望 IP",
|
||||
"status": "状态",
|
||||
"proxied": "橙云",
|
||||
"actions": "操作"
|
||||
@@ -830,19 +821,8 @@
|
||||
"groupDialog": {
|
||||
"editTitle": "编辑指向分组",
|
||||
"createTitle": "新增指向分组",
|
||||
"description": "选择绑定边缘节点,或为成员统一指定自定义 DNS 记录目标。",
|
||||
"description": "一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。",
|
||||
"name": "分组名称",
|
||||
"targetMode": "指向方式",
|
||||
"bindNode": "绑定节点",
|
||||
"customTarget": "自定义",
|
||||
"recordType": "记录类型",
|
||||
"recordContent": "记录值",
|
||||
"recordContentHint": "A 填 IPv4,AAAA 填 IPv6,CNAME 填目标域名。",
|
||||
"recordContentPlaceholder": {
|
||||
"CNAME": "例如:origin.example.com",
|
||||
"A": "例如:203.0.113.10",
|
||||
"AAAA": "例如:2001:db8::10"
|
||||
},
|
||||
"primary": "主节点",
|
||||
"primaryPlaceholder": "选择带 IPv4 的边缘节点",
|
||||
"noIp": "未配置 IP",
|
||||
@@ -854,7 +834,7 @@
|
||||
},
|
||||
"memberDialog": {
|
||||
"title": "添加域名成员",
|
||||
"description": "支持搜索筛选与批量勾选;加入后会创建或接管当前分组指向所用的唯一同名 DNS 记录。",
|
||||
"description": "支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名 A 会拒绝同步。",
|
||||
"zoneDomain": "Zone 域名",
|
||||
"searchPlaceholder": "搜索域名或顶级域…",
|
||||
"selected": "已选 {count}",
|
||||
|
||||
@@ -380,8 +380,6 @@
|
||||
"redirectHttpNeedsCert": "At least one selected domain must have a bound certificate before this can be enabled.",
|
||||
"reverseProxy": "Reverse proxy",
|
||||
"reverseProxyDesc": "Configure the origin strategy and upstream addresses.",
|
||||
"enableHttp2": "Enable HTTP/2",
|
||||
"enableHttp2Desc": "Enabled by default. When disabled, HTTPS traffic will not use HTTP/2.",
|
||||
"proxySaved": "Reverse proxy settings saved",
|
||||
"originHostHint": "If left empty, the request hostname $host is passed through by default.",
|
||||
"customHeaders": "Custom request headers",
|
||||
|
||||
@@ -380,8 +380,6 @@
|
||||
"redirectHttpNeedsCert": "所选域名至少绑定一张证书后才能启用。",
|
||||
"reverseProxy": "反向代理",
|
||||
"reverseProxyDesc": "配置请求回源上游的策略与地址。",
|
||||
"enableHttp2": "启用 HTTP/2",
|
||||
"enableHttp2Desc": "默认开启。关闭后,HTTPS 访问将不再使用 HTTP/2。",
|
||||
"proxySaved": "反向代理设置已保存",
|
||||
"originHostHint": "留空时默认透传访问域名 $host。",
|
||||
"customHeaders": "自定义请求头",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"axios": "^1.20.0",
|
||||
"axios": "^1.19.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -61,7 +61,7 @@
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
"motion": "^12.43.0",
|
||||
"next": "16.3.6",
|
||||
"next": "16.3.0",
|
||||
"next-intl": "^4.13.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"pinyin-match": "^1.2.10",
|
||||
|
||||
Generated
+345
-353
File diff suppressed because it is too large
Load Diff
@@ -13,22 +13,8 @@ import { CloudflareService, NodeService } from '@/lib/services/openflare';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import zhCN from '@/messages/zh-CN.json';
|
||||
|
||||
let mockGroupId = '7';
|
||||
let mockParamId = '7';
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
}) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: mockParamId }),
|
||||
usePathname: () => `/cloudflare/groups/${mockGroupId}`,
|
||||
useParams: () => ({ id: '7' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
@@ -39,80 +25,17 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
CloudflareService: {
|
||||
...actual.CloudflareService,
|
||||
getGroup: vi.fn(),
|
||||
listGroups: vi.fn(),
|
||||
listAvailableDomains: vi.fn(),
|
||||
updateGroup: vi.fn(),
|
||||
createMember: vi.fn(),
|
||||
updateMember: vi.fn(),
|
||||
syncMember: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
moveMember: vi.fn(),
|
||||
batchMoveMembers: vi.fn(),
|
||||
batchRemoveMembers: vi.fn(),
|
||||
},
|
||||
NodeService: { ...actual.NodeService, listNodes: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
const mockGroup = {
|
||||
id: 7,
|
||||
name: '生产节点',
|
||||
target_mode: 'node' as const,
|
||||
record_type: 'A' as const,
|
||||
record_content: '',
|
||||
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
backup_node: null,
|
||||
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
default_proxied: true,
|
||||
enabled: true,
|
||||
member_count: 2,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
|
||||
const mockTargetGroup = {
|
||||
id: 8,
|
||||
name: '备用分组',
|
||||
target_mode: 'node' as const,
|
||||
record_type: 'A' as const,
|
||||
record_content: '',
|
||||
primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||
backup_node: null,
|
||||
active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||
default_proxied: false,
|
||||
enabled: true,
|
||||
member_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
|
||||
const mockMembers = [
|
||||
{
|
||||
id: 101,
|
||||
group_id: 7,
|
||||
zone_domain_id: 1,
|
||||
domain: 'a.example.com',
|
||||
zone_id: 10,
|
||||
proxied: true,
|
||||
desired_ip: '192.0.2.1',
|
||||
sync_status: 'ok' as const,
|
||||
last_error: '',
|
||||
synced_at: null,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
group_id: 7,
|
||||
zone_domain_id: 2,
|
||||
domain: 'b.example.com',
|
||||
zone_id: 10,
|
||||
proxied: false,
|
||||
desired_ip: '192.0.2.1',
|
||||
sync_status: 'ok' as const,
|
||||
last_error: '',
|
||||
synced_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
@@ -132,39 +55,26 @@ function renderPage() {
|
||||
|
||||
describe('Cloudflare group detail refresh', () => {
|
||||
beforeEach(() => {
|
||||
mockGroupId = '7';
|
||||
mockParamId = '7';
|
||||
vi.mocked(CloudflareService.getGroup).mockReset();
|
||||
vi.mocked(CloudflareService.listGroups).mockReset();
|
||||
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
|
||||
vi.mocked(CloudflareService.moveMember).mockReset();
|
||||
vi.mocked(CloudflareService.batchMoveMembers).mockReset();
|
||||
vi.mocked(CloudflareService.batchRemoveMembers).mockReset();
|
||||
vi.mocked(NodeService.listNodes).mockReset();
|
||||
|
||||
window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
window.HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
|
||||
group: mockGroup,
|
||||
group: {
|
||||
id: 7,
|
||||
name: '生产节点',
|
||||
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
backup_node: null,
|
||||
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
default_proxied: true,
|
||||
enabled: true,
|
||||
member_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
members: [],
|
||||
});
|
||||
vi.mocked(CloudflareService.listGroups).mockResolvedValue([
|
||||
mockGroup,
|
||||
mockTargetGroup,
|
||||
]);
|
||||
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
|
||||
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
|
||||
vi.mocked(CloudflareService.moveMember).mockResolvedValue({
|
||||
...mockMembers[0],
|
||||
group_id: 8,
|
||||
});
|
||||
vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined);
|
||||
vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -184,19 +94,6 @@ describe('Cloudflare group detail refresh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the browser pathname ID when serving a static-export fallback shell', async () => {
|
||||
mockParamId = '1';
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CloudflareService.getGroup).toHaveBeenCalledWith(7);
|
||||
});
|
||||
expect(CloudflareService.getGroup).not.toHaveBeenCalledWith(1);
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '生产节点' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('automatically refreshes detail data every five seconds', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderPage();
|
||||
@@ -215,174 +112,3 @@ describe('Cloudflare group detail refresh', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloudflare group detail move and batch operations', () => {
|
||||
beforeEach(() => {
|
||||
mockGroupId = '7';
|
||||
mockParamId = '7';
|
||||
vi.mocked(CloudflareService.getGroup).mockReset();
|
||||
vi.mocked(CloudflareService.listGroups).mockReset();
|
||||
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
|
||||
vi.mocked(CloudflareService.moveMember).mockReset();
|
||||
vi.mocked(CloudflareService.batchMoveMembers).mockReset();
|
||||
vi.mocked(CloudflareService.batchRemoveMembers).mockReset();
|
||||
vi.mocked(NodeService.listNodes).mockReset();
|
||||
|
||||
window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||
window.HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
|
||||
group: mockGroup,
|
||||
members: mockMembers,
|
||||
});
|
||||
vi.mocked(CloudflareService.listGroups).mockResolvedValue([
|
||||
mockGroup,
|
||||
mockTargetGroup,
|
||||
]);
|
||||
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
|
||||
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
|
||||
vi.mocked(CloudflareService.moveMember).mockResolvedValue({
|
||||
...mockMembers[0],
|
||||
group_id: 8,
|
||||
});
|
||||
vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined);
|
||||
vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports selecting all members and toggling selection', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||
expect(screen.getByText('b.example.com')).toBeVisible();
|
||||
|
||||
// Initially batch toolbar is not visible
|
||||
expect(screen.queryByText(/已选择/)).not.toBeInTheDocument();
|
||||
|
||||
// Select all via header checkbox
|
||||
const selectAllCheckbox = screen.getByRole('checkbox', {
|
||||
name: 'Select all',
|
||||
});
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
// Batch toolbar should appear with count 2
|
||||
expect(await screen.findByText('已选择 2 项')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '批量移动' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '批量删除' })).toBeVisible();
|
||||
|
||||
// Clear selection
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消选择' }));
|
||||
expect(screen.queryByText(/已选择/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens single move dialog and triggers moveMember', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||
|
||||
// Click single move button on first row
|
||||
const moveButtons = screen.getAllByRole('button', { name: '移动' });
|
||||
fireEvent.click(moveButtons[0]);
|
||||
|
||||
// Move dialog should show single member title
|
||||
expect(
|
||||
await screen.findByRole('heading', {
|
||||
name: '移动域名「a.example.com」',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Open target group select
|
||||
const selectTrigger = screen.getByRole('combobox');
|
||||
fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' });
|
||||
|
||||
// Select target group
|
||||
const targetOption = await screen.findByRole('option', {
|
||||
name: /备用分组/,
|
||||
});
|
||||
fireEvent.click(targetOption);
|
||||
|
||||
// Submit move
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认移动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CloudflareService.moveMember).toHaveBeenCalledWith(7, 101, 8);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens batch move dialog and triggers batchMoveMembers', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||
|
||||
// Select all members
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Select all' }));
|
||||
expect(await screen.findByText('已选择 2 项')).toBeVisible();
|
||||
|
||||
// Click batch move button
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量移动' }));
|
||||
|
||||
// Dialog title should show batch title
|
||||
expect(
|
||||
await screen.findByRole('heading', {
|
||||
name: '批量移动域名(共 2 项)',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Open target group select
|
||||
const selectTrigger = screen.getByRole('combobox');
|
||||
fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' });
|
||||
|
||||
// Select target group
|
||||
const targetOption = await screen.findByRole('option', {
|
||||
name: /备用分组/,
|
||||
});
|
||||
fireEvent.click(targetOption);
|
||||
|
||||
// Submit move
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认移动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CloudflareService.batchMoveMembers).toHaveBeenCalledWith(
|
||||
7,
|
||||
[101, 102],
|
||||
8,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens batch remove dialog and triggers batchRemoveMembers', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||
|
||||
// Select first member only
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', { name: 'Select a.example.com' }),
|
||||
);
|
||||
expect(await screen.findByText('已选择 1 项')).toBeVisible();
|
||||
|
||||
// Click batch remove
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量删除' }));
|
||||
|
||||
// Confirmation dialog appears
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '确认批量删除域名' }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(/确认从当前分组移出已选中的 1 个域名吗?/),
|
||||
).toBeVisible();
|
||||
|
||||
// Confirm deletion
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CloudflareService.batchRemoveMembers).toHaveBeenCalledWith(
|
||||
7,
|
||||
[101],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,9 +109,6 @@ describe('Cloudflare overview', () => {
|
||||
{
|
||||
id: 7,
|
||||
name: '生产节点',
|
||||
target_mode: 'node',
|
||||
record_type: 'A',
|
||||
record_content: '',
|
||||
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
backup_node: null,
|
||||
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||
|
||||
@@ -55,29 +55,4 @@ describe('CloudflareService', () => {
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls move and batch member endpoints with correct paths and payloads', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(response({}));
|
||||
|
||||
await CloudflareService.moveMember(7, 9, 8);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/cloudflare/groups/7/members/9/move',
|
||||
{ target_group_id: 8 },
|
||||
undefined,
|
||||
);
|
||||
|
||||
await CloudflareService.batchMoveMembers(7, [9, 10], 8);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/cloudflare/groups/7/members/batch-move',
|
||||
{ member_ids: [9, 10], target_group_id: 8 },
|
||||
undefined,
|
||||
);
|
||||
|
||||
await CloudflareService.batchRemoveMembers(7, [9, 10]);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/cloudflare/groups/7/members/batch-remove',
|
||||
{ member_ids: [9, 10] },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hibiken/asynq v0.26.0
|
||||
github.com/maypok86/otter/v2 v2.3.0
|
||||
@@ -51,10 +50,10 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.45.0
|
||||
go.opentelemetry.io/otel/trace v1.45.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/mod v0.38.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.22.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -125,6 +124,7 @@ require (
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
@@ -177,12 +177,12 @@ require (
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||
golang.org/x/arch v0.29.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
google.golang.org/grpc v1.83.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/clickhouse v0.7.0 // indirect
|
||||
|
||||
@@ -389,8 +389,8 @@ golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
|
||||
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -400,8 +400,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -423,8 +423,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -439,8 +439,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -62,28 +62,14 @@ type Runner struct {
|
||||
RuntimeManager RuntimeManager
|
||||
WebSocketService WebSocketService
|
||||
|
||||
configSyncRetry *configSyncRetry
|
||||
restartOpenrestyNow bool
|
||||
websocketUpgradeEnabled bool
|
||||
}
|
||||
|
||||
// Run starts the agent's main loop, performing heartbeats and upgrading to WebSocket when available.
|
||||
func (r *Runner) Run(ctx context.Context) error {
|
||||
if r.SyncService != nil {
|
||||
r.configSyncRetry = newConfigSyncRetry(r.SyncService, r.recordSyncError)
|
||||
go r.configSyncRetry.run(ctx)
|
||||
if snapshot, loadErr := r.StateStore.Load(); loadErr != nil {
|
||||
slog.Error("load state before scheduling config sync retry failed", "error", loadErr)
|
||||
} else if strings.TrimSpace(snapshot.BlockedVersion) != "" || strings.TrimSpace(snapshot.BlockedChecksum) != "" {
|
||||
r.configSyncRetry.schedule(&protocol.ActiveConfigMeta{
|
||||
Version: snapshot.BlockedVersion,
|
||||
Checksum: snapshot.BlockedChecksum,
|
||||
})
|
||||
}
|
||||
}
|
||||
if r.HeartbeatCycle != nil {
|
||||
r.HeartbeatCycle.RecordSyncError = r.recordSyncError
|
||||
r.HeartbeatCycle.ConfigSyncResult = r.handleConfigSyncResult
|
||||
}
|
||||
nodeID, err := r.StateStore.EnsureNodeID()
|
||||
if err != nil {
|
||||
@@ -327,11 +313,10 @@ func (r *Runner) handleWebSocketMessage(ctx context.Context, message protocol.WS
|
||||
return false, nil
|
||||
}
|
||||
slog.Debug("agent ws active config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true)
|
||||
err := r.SyncService.SyncOnce(ctx, &target)
|
||||
if err != nil {
|
||||
if err := r.SyncService.SyncOnce(ctx, &target); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent ws triggered sync failed", "version", target.Version, "error", err)
|
||||
}
|
||||
r.handleConfigSyncResult(&target, err)
|
||||
return false, nil
|
||||
case protocol.WSMessageTypeForceSyncConfig:
|
||||
var target protocol.ActiveConfigMeta
|
||||
@@ -340,11 +325,10 @@ func (r *Runner) handleWebSocketMessage(ctx context.Context, message protocol.WS
|
||||
return false, nil
|
||||
}
|
||||
slog.Debug("agent ws force sync config received", "version", target.Version, "checksum", target.Checksum, "trigger_sync", true)
|
||||
err := r.SyncService.ForceSyncOnce(ctx, &target)
|
||||
if err != nil {
|
||||
if err := r.SyncService.ForceSyncOnce(ctx, &target); err != nil {
|
||||
r.recordSyncError(err)
|
||||
slog.Error("agent ws triggered force sync failed", "version", target.Version, "error", err)
|
||||
}
|
||||
r.handleConfigSyncResult(&target, err)
|
||||
return false, nil
|
||||
case protocol.WSMessageTypeWAFIPGroups:
|
||||
var groups []protocol.WAFIPGroup
|
||||
@@ -366,27 +350,6 @@ func (r *Runner) handleWebSocketMessage(ctx context.Context, message protocol.WS
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) handleConfigSyncResult(target *protocol.ActiveConfigMeta, err error) {
|
||||
if err != nil {
|
||||
r.recordSyncError(err)
|
||||
if r.configSyncRetry != nil {
|
||||
r.configSyncRetry.schedule(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.configSyncRetry == nil || r.StateStore == nil {
|
||||
return
|
||||
}
|
||||
snapshot, loadErr := r.StateStore.Load()
|
||||
if loadErr != nil {
|
||||
slog.Error("load state after config sync failed", "error", loadErr)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(snapshot.BlockedVersion) == "" && strings.TrimSpace(snapshot.BlockedChecksum) == "" {
|
||||
r.configSyncRetry.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
type webSocketBackoff struct {
|
||||
delays []time.Duration
|
||||
index int
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
configSyncRetryInitialDelay = 10 * time.Second
|
||||
configSyncRetryMaxDelay = 5 * time.Minute
|
||||
)
|
||||
|
||||
type configSyncRetryEvent struct {
|
||||
target *protocol.ActiveConfigMeta
|
||||
resolve bool
|
||||
}
|
||||
|
||||
type configSyncRetry struct {
|
||||
syncService SyncService
|
||||
recordError func(error)
|
||||
mu sync.Mutex
|
||||
pending *configSyncRetryEvent
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func newConfigSyncRetry(syncService SyncService, recordError func(error)) *configSyncRetry {
|
||||
return &configSyncRetry{
|
||||
syncService: syncService,
|
||||
recordError: recordError,
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *configSyncRetry) schedule(target *protocol.ActiveConfigMeta) {
|
||||
r.notify(configSyncRetryEvent{target: cloneActiveConfigMeta(target)})
|
||||
}
|
||||
|
||||
func (r *configSyncRetry) resolve() {
|
||||
r.notify(configSyncRetryEvent{resolve: true})
|
||||
}
|
||||
|
||||
func (r *configSyncRetry) notify(event configSyncRetryEvent) {
|
||||
r.mu.Lock()
|
||||
r.pending = &event
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case r.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (r *configSyncRetry) takePending() (configSyncRetryEvent, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.pending == nil {
|
||||
return configSyncRetryEvent{}, false
|
||||
}
|
||||
event := *r.pending
|
||||
r.pending = nil
|
||||
return event, true
|
||||
}
|
||||
|
||||
func (r *configSyncRetry) run(ctx context.Context) {
|
||||
var target *protocol.ActiveConfigMeta
|
||||
var timer *time.Timer
|
||||
var timerC <-chan time.Time
|
||||
attempt := 0
|
||||
pending := false
|
||||
defer func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-r.wake:
|
||||
event, ok := r.takePending()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if event.resolve {
|
||||
target = nil
|
||||
attempt = 0
|
||||
pending = false
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timerC = nil
|
||||
continue
|
||||
}
|
||||
if !pending || !sameConfigTarget(target, event.target) {
|
||||
target = event.target
|
||||
attempt = 0
|
||||
pending = true
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.NewTimer(configSyncRetryDelay(attempt))
|
||||
timerC = timer.C
|
||||
}
|
||||
case <-timerC:
|
||||
if err := r.syncService.ForceSyncOnce(ctx, target); err != nil {
|
||||
attempt++
|
||||
delay := configSyncRetryDelay(attempt)
|
||||
if r.recordError != nil {
|
||||
r.recordError(err)
|
||||
}
|
||||
slog.Warn("agent forced config sync retry failed", "attempt", attempt, "retry_after", delay, "error", err)
|
||||
timer.Reset(delay)
|
||||
timerC = timer.C
|
||||
continue
|
||||
}
|
||||
slog.Info("agent forced config sync retry succeeded", "attempt", attempt+1)
|
||||
target = nil
|
||||
attempt = 0
|
||||
pending = false
|
||||
timerC = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func configSyncRetryDelay(attempt int) time.Duration {
|
||||
delay := configSyncRetryInitialDelay
|
||||
for i := 0; i < attempt && delay < configSyncRetryMaxDelay; i++ {
|
||||
delay *= 2
|
||||
if delay > configSyncRetryMaxDelay {
|
||||
return configSyncRetryMaxDelay
|
||||
}
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func sameConfigTarget(left, right *protocol.ActiveConfigMeta) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == nil && right == nil
|
||||
}
|
||||
return strings.TrimSpace(left.Version) == strings.TrimSpace(right.Version) &&
|
||||
strings.TrimSpace(left.Checksum) == strings.TrimSpace(right.Checksum)
|
||||
}
|
||||
|
||||
func cloneActiveConfigMeta(target *protocol.ActiveConfigMeta) *protocol.ActiveConfigMeta {
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *target
|
||||
return &cloned
|
||||
}
|
||||
@@ -43,7 +43,6 @@ type Cycle struct {
|
||||
Sync SyncService
|
||||
Updater *updater.Service
|
||||
RecordSyncError func(err error)
|
||||
ConfigSyncResult func(target *protocol.ActiveConfigMeta, err error)
|
||||
}
|
||||
|
||||
// Perform executes one complete heartbeat cycle: sends the heartbeat, syncs config, and applies settings.
|
||||
@@ -70,16 +69,14 @@ func (c *Cycle) Perform(ctx context.Context, nodeID string, startup bool, settin
|
||||
c.ApplyWAFIPGroups(ctx, heartbeatResult.WAFIPGroups)
|
||||
if startup {
|
||||
if err = c.Sync.SyncOnStartup(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
c.recordSyncError(err)
|
||||
slog.Error("agent startup sync failed", "error", err)
|
||||
} else {
|
||||
slog.Debug("agent startup sync completed")
|
||||
}
|
||||
c.reportConfigSyncResult(heartbeatResult.ActiveConfig, err)
|
||||
} else if err = c.Sync.SyncOnce(ctx, heartbeatResult.ActiveConfig); err != nil {
|
||||
c.recordSyncError(err)
|
||||
slog.Error("agent sync failed", "error", err)
|
||||
c.reportConfigSyncResult(heartbeatResult.ActiveConfig, err)
|
||||
} else {
|
||||
c.reportConfigSyncResult(heartbeatResult.ActiveConfig, nil)
|
||||
}
|
||||
if settings != nil {
|
||||
settings.RestartOpenrestyIfNeeded(ctx)
|
||||
@@ -88,16 +85,6 @@ func (c *Cycle) Perform(ctx context.Context, nodeID string, startup bool, settin
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (c *Cycle) reportConfigSyncResult(target *protocol.ActiveConfigMeta, err error) {
|
||||
if c.ConfigSyncResult != nil {
|
||||
c.ConfigSyncResult(target, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.recordSyncError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// NodePayload builds and returns the full NodePayload to be sent in a heartbeat request.
|
||||
func (c *Cycle) NodePayload(ctx context.Context, nodeID string) protocol.NodePayload {
|
||||
snapshot, _ := c.StateStore.Load()
|
||||
|
||||
@@ -112,11 +112,9 @@ func (r *OSCommandRunner) Run(ctx context.Context, name string, args ...string)
|
||||
|
||||
// PathExecutor runs OpenResty using a configured binary and config path.
|
||||
type PathExecutor struct {
|
||||
Path string
|
||||
ConfigPath string
|
||||
Runner CommandRunner
|
||||
inspectProcesses func() ([]runtimeProcess, error)
|
||||
signalProcess func(runtimeProcess, bool) error
|
||||
Path string
|
||||
ConfigPath string
|
||||
Runner CommandRunner
|
||||
}
|
||||
|
||||
// Test validates the current OpenResty configuration.
|
||||
@@ -132,7 +130,21 @@ func (e *PathExecutor) Test(ctx context.Context) error {
|
||||
|
||||
// Reload reloads OpenResty or starts it when no runtime process is running.
|
||||
func (e *PathExecutor) Reload(ctx context.Context) error {
|
||||
return e.recoverRuntime(ctx)
|
||||
slog.Debug("running openresty reload with binary", "path", e.Path, "config", e.ConfigPath)
|
||||
output, err := e.Runner.Run(ctx, e.Path, "-s", "reload", "-c", e.ConfigPath)
|
||||
if err != nil {
|
||||
if isOpenrestyNotRunningError(string(output)) {
|
||||
slog.Warn("openresty reload reported runtime is not running, starting binary", "path", e.Path)
|
||||
startOutput, startErr := e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath)
|
||||
if startErr != nil {
|
||||
return fmt.Errorf("openresty reload failed: %w: %s; start failed: %w: %s", err, string(output), startErr, string(startOutput))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("openresty reload failed: %w: %s", err, string(output))
|
||||
}
|
||||
slog.Debug("openresty reload succeeded with binary", "path", e.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureRuntime validates configuration and reloads the OpenResty runtime.
|
||||
@@ -150,7 +162,20 @@ func (e *PathExecutor) CheckHealth(ctx context.Context) error {
|
||||
|
||||
// Restart stops and starts the OpenResty runtime process.
|
||||
func (e *PathExecutor) Restart(ctx context.Context) error {
|
||||
return e.restartRuntime(ctx)
|
||||
slog.Info("restarting openresty with binary", "path", e.Path, "config", e.ConfigPath)
|
||||
output, err := e.Runner.Run(ctx, e.Path, "-s", "quit", "-c", e.ConfigPath)
|
||||
if err != nil {
|
||||
text := string(output)
|
||||
if !isIgnorableOpenrestyStopError(text) {
|
||||
return fmt.Errorf("openresty stop failed: %w: %s", err, text)
|
||||
}
|
||||
}
|
||||
output, err = e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("openresty start failed: %w: %s", err, string(output))
|
||||
}
|
||||
slog.Info("openresty restart succeeded with binary", "path", e.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Manager applies OpenResty configuration and manages runtime assets.
|
||||
@@ -172,7 +197,6 @@ type Manager struct {
|
||||
Executor Executor
|
||||
atomicFileWriter func(path string, data []byte, perm os.FileMode) error
|
||||
wafIPGroupsMu sync.Mutex
|
||||
runtimeMu sync.Mutex
|
||||
}
|
||||
|
||||
// ApplyStatus reports the outcome of an OpenResty configuration apply.
|
||||
@@ -237,8 +261,6 @@ type wafIPGroupsRuntimeConfig struct {
|
||||
|
||||
// Apply writes, validates, and activates new OpenResty configuration files.
|
||||
func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles))
|
||||
backup, err := m.backup()
|
||||
if err != nil {
|
||||
@@ -342,7 +364,7 @@ func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupSt
|
||||
if backup != nil && backup.MainExisted {
|
||||
return fatalApplyOutcome(fmt.Errorf("apply failed: %w; rollback recovery failed: %w", applyErr, err))
|
||||
}
|
||||
if fallbackErr := m.ensureSafeFallbackRuntime(ctx, fmt.Sprintf("apply failed: %v; rollback recovery failed: %v", applyErr, err)); fallbackErr != nil {
|
||||
if fallbackErr := m.EnsureSafeFallbackRuntime(ctx, fmt.Sprintf("apply failed: %v; rollback recovery failed: %v", applyErr, err)); fallbackErr != nil {
|
||||
return fatalApplyOutcome(fmt.Errorf("apply failed: %w; rollback recovery failed: %w; fallback recovery failed: %w", applyErr, err, fallbackErr))
|
||||
}
|
||||
message := fmt.Sprintf("apply failed, but fallback runtime started: %v; rollback recovery failed: %v", applyErr, err)
|
||||
@@ -404,8 +426,6 @@ func (m *Manager) EnsureLuaAssets() error {
|
||||
|
||||
// EnsureRuntime validates and reloads the current OpenResty runtime configuration.
|
||||
func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
}
|
||||
@@ -415,12 +435,6 @@ func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
|
||||
// EnsureSafeFallbackRuntime starts a minimal safe default OpenResty runtime.
|
||||
func (m *Manager) EnsureSafeFallbackRuntime(ctx context.Context, reason string) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
return m.ensureSafeFallbackRuntime(ctx, reason)
|
||||
}
|
||||
|
||||
func (m *Manager) ensureSafeFallbackRuntime(ctx context.Context, reason string) error {
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
}
|
||||
@@ -441,8 +455,6 @@ func (m *Manager) ensureSafeFallbackRuntime(ctx context.Context, reason string)
|
||||
|
||||
// CheckHealth verifies that OpenResty configuration and health endpoints are available.
|
||||
func (m *Manager) CheckHealth(ctx context.Context) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
}
|
||||
@@ -459,8 +471,6 @@ func (m *Manager) CheckHealth(ctx context.Context) error {
|
||||
|
||||
// Restart restarts the OpenResty runtime process.
|
||||
func (m *Manager) Restart(ctx context.Context) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
}
|
||||
@@ -470,8 +480,6 @@ func (m *Manager) Restart(ctx context.Context) error {
|
||||
|
||||
// CurrentChecksum returns a stable checksum for the active OpenResty configuration bundle.
|
||||
func (m *Manager) CurrentChecksum() (string, error) {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.RouteConfigPath == "" {
|
||||
return "", errors.New("route config path 不能为空")
|
||||
}
|
||||
@@ -702,7 +710,7 @@ func writeAtomicFile(path string, data []byte, perm os.FileMode) (resultErr erro
|
||||
resultErr = closeErr
|
||||
}
|
||||
}
|
||||
_ = os.Remove(tempPath) //nolint:gosec // Temp path is created by os.CreateTemp in the managed destination directory.
|
||||
_ = os.Remove(tempPath)
|
||||
}()
|
||||
if err = tempFile.Chmod(perm); err != nil {
|
||||
return err
|
||||
@@ -717,7 +725,7 @@ func writeAtomicFile(path string, data []byte, perm os.FileMode) (resultErr erro
|
||||
return err
|
||||
}
|
||||
closed = true
|
||||
if err = os.Rename(tempPath, path); err != nil { //nolint:gosec // Destination is an Agent-managed runtime file (including the validated pid directive).
|
||||
if err = os.Rename(tempPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -809,6 +817,24 @@ func parseExtVersion(output string) string {
|
||||
|
||||
var nginxVersionPattern = regexp.MustCompile(`(?im)(?:nginx|openresty) version:\s*(?:nginx|openresty)/(\S+)`)
|
||||
|
||||
func isIgnorableOpenrestyStopError(output string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(output))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "invalid pid") || strings.Contains(text, "no such process")
|
||||
}
|
||||
|
||||
func isOpenrestyNotRunningError(output string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(output))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "invalid pid") ||
|
||||
strings.Contains(text, "no such process") ||
|
||||
strings.Contains(text, "open()") && strings.Contains(text, "nginx.pid") && strings.Contains(text, "failed")
|
||||
}
|
||||
|
||||
type backupState struct {
|
||||
MainExisted bool
|
||||
MainData []byte
|
||||
|
||||
@@ -104,10 +104,9 @@ func (e *scriptedExecutor) Restart(ctx context.Context) error {
|
||||
func TestPathExecutorCommands(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return nil, nil },
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
if err := executor.Test(context.Background()); err != nil {
|
||||
@@ -119,7 +118,7 @@ func TestPathExecutorCommands(t *testing.T) {
|
||||
|
||||
expected := []runCall{
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-t", "-c", "/data/etc/nginx/nginx.conf"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-c", "/data/etc/nginx/nginx.conf"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}},
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls, expected) {
|
||||
t.Fatalf("unexpected calls: %#v", runner.calls)
|
||||
@@ -129,10 +128,9 @@ func TestPathExecutorCommands(t *testing.T) {
|
||||
func TestPathExecutorEnsureRuntimeNoop(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return nil, nil },
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
}
|
||||
if err := executor.EnsureRuntime(context.Background(), true); err != nil {
|
||||
t.Fatalf("EnsureRuntime failed: %v", err)
|
||||
@@ -142,34 +140,47 @@ func TestPathExecutorEnsureRuntimeNoop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathExecutorRestartStartsWhenNoProcesses(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
func TestPathExecutorRestartIgnoresMissingPID(t *testing.T) {
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) == 2 && args[0] == "-s" && args[1] == "quit" {
|
||||
return []byte("openresty: [error] invalid PID number \"\" in \"/usr/local/openresty/nginx/logs/nginx.pid\""), errors.New("exit status 1")
|
||||
}
|
||||
return []byte(""), nil
|
||||
},
|
||||
}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return nil, nil },
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
}
|
||||
if err := executor.Restart(context.Background()); err != nil {
|
||||
t.Fatalf("Restart failed: %v", err)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("expected 1 start call, got %d", len(runner.calls))
|
||||
if len(runner.calls) != 2 {
|
||||
t.Fatalf("expected 2 restart calls, got %d", len(runner.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathExecutorReloadStartsWhenRuntimeIsNotRunning(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
runner := &fakeRunner{
|
||||
runFn: func(name string, args ...string) ([]byte, error) {
|
||||
if len(args) >= 2 && args[0] == "-s" && args[1] == "reload" {
|
||||
return []byte("openresty: [error] invalid PID number \"\" in \"/usr/local/openresty/nginx/logs/nginx.pid\""), errors.New("exit status 1")
|
||||
}
|
||||
return []byte(""), nil
|
||||
},
|
||||
}
|
||||
executor := &PathExecutor{
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return nil, nil },
|
||||
Path: "/usr/local/openresty/nginx/sbin/openresty",
|
||||
ConfigPath: "/data/etc/nginx/nginx.conf",
|
||||
Runner: runner,
|
||||
}
|
||||
if err := executor.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload failed: %v", err)
|
||||
}
|
||||
expected := []runCall{
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-s", "reload", "-c", "/data/etc/nginx/nginx.conf"}},
|
||||
{name: "/usr/local/openresty/nginx/sbin/openresty", args: []string{"-c", "/data/etc/nginx/nginx.conf"}},
|
||||
}
|
||||
if !reflect.DeepEqual(runner.calls, expected) {
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeShutdownTimeout = 10 * time.Second
|
||||
runtimeProcessPollInterval = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// runtimeProcess records an inspected process identity, including its start time
|
||||
// to reject a PID that has since been reused.
|
||||
type runtimeProcess struct {
|
||||
PID int
|
||||
ParentPID int
|
||||
Master bool
|
||||
StartTime string
|
||||
}
|
||||
|
||||
// runtimeProcesses returns candidates; only verified masters and their tracked
|
||||
// descendants establish membership of this instance.
|
||||
func (e *PathExecutor) runtimeProcesses(ctx context.Context) ([]runtimeProcess, error) {
|
||||
if e.inspectProcesses != nil {
|
||||
return e.inspectProcesses()
|
||||
}
|
||||
return inspectOpenrestyProcesses(ctx, e.Path, e.ConfigPath)
|
||||
}
|
||||
|
||||
func (e *PathExecutor) signalRuntime(ctx context.Context, process runtimeProcess, quit bool) error {
|
||||
if e.signalProcess != nil {
|
||||
return e.signalProcess(process, quit)
|
||||
}
|
||||
return signalOpenrestyProcess(ctx, e.Path, e.ConfigPath, process, quit)
|
||||
}
|
||||
|
||||
func findRuntimeMaster(processes []runtimeProcess) (runtimeProcess, error) {
|
||||
var master runtimeProcess
|
||||
for _, process := range processes {
|
||||
if !process.Master {
|
||||
continue
|
||||
}
|
||||
if master.PID != 0 {
|
||||
return runtimeProcess{}, errors.New("multiple matching openresty masters; refusing ambiguous runtime recovery")
|
||||
}
|
||||
master = process
|
||||
}
|
||||
return master, nil
|
||||
}
|
||||
|
||||
// runtimeMasterIdentities seeds instance membership from verified masters.
|
||||
func runtimeMasterIdentities(processes []runtimeProcess) map[int]string {
|
||||
identities := make(map[int]string)
|
||||
for _, process := range processes {
|
||||
if process.Master {
|
||||
identities[process.PID] = process.StartTime
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// runtimeDescendants selects known identities and their children. Keeping the
|
||||
// identities between shutdown polls also recognizes workers reparented to init.
|
||||
func runtimeDescendants(processes []runtimeProcess, identities map[int]string) []runtimeProcess {
|
||||
selected := make(map[int]bool)
|
||||
for _, process := range processes {
|
||||
if start, ok := identities[process.PID]; ok && start == process.StartTime {
|
||||
selected[process.PID] = true
|
||||
}
|
||||
}
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
for _, process := range processes {
|
||||
if !selected[process.PID] && selected[process.ParentPID] {
|
||||
selected[process.PID] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var result []runtimeProcess
|
||||
for _, process := range processes {
|
||||
if selected[process.PID] {
|
||||
identities[process.PID] = process.StartTime
|
||||
result = append(result, process)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *PathExecutor) recoverRuntime(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
processes, err := e.runtimeProcesses(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect openresty runtime: %w", err)
|
||||
}
|
||||
master, err := findRuntimeMaster(processes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if master.PID != 0 {
|
||||
repaired, err := e.repairPIDFile(master.PID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.signalRuntime(ctx, master, false); err != nil {
|
||||
return fmt.Errorf("reload recovered openresty master: %w", err)
|
||||
}
|
||||
if repaired {
|
||||
slog.WarnContext(ctx, "recovered openresty master with invalid pid file", "pid", master.PID, "config", e.ConfigPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return e.startRuntime(ctx)
|
||||
}
|
||||
|
||||
func (e *PathExecutor) startRuntime(ctx context.Context) error {
|
||||
output, err := e.Runner.Run(ctx, e.Path, "-c", e.ConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("openresty start failed: %w: %s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var pidDirectivePattern = regexp.MustCompile(`(?m)^\s*pid\s+([^;\n]+);`)
|
||||
|
||||
func (e *PathExecutor) repairPIDFile(pid int) (bool, error) {
|
||||
config, err := os.ReadFile(e.ConfigPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read config for pid recovery: %w", err)
|
||||
}
|
||||
matches := pidDirectivePattern.FindAllSubmatch(config, -1)
|
||||
if len(matches) != 1 {
|
||||
return false, errors.New("pid recovery requires one explicit pid directive")
|
||||
}
|
||||
path := strings.Trim(string(matches[0][1]), " \t\"'")
|
||||
if !filepath.IsAbs(path) {
|
||||
return false, errors.New("pid recovery requires an absolute pid path")
|
||||
}
|
||||
expected := strconv.Itoa(pid) + "\n"
|
||||
//nolint:gosec // PID path comes from the validated, Agent-managed main configuration.
|
||||
if data, err := os.ReadFile(path); err == nil && string(data) == expected {
|
||||
return false, nil
|
||||
}
|
||||
if err := writeAtomicFile(path, []byte(strconv.Itoa(pid)+"\n"), nginxConfigFilePerm); err != nil {
|
||||
return false, fmt.Errorf("repair openresty pid file: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *PathExecutor) restartRuntime(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
processes, err := e.runtimeProcesses(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
master, err := findRuntimeMaster(processes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if master.PID == 0 {
|
||||
return e.startRuntime(ctx)
|
||||
}
|
||||
identities := runtimeMasterIdentities(processes)
|
||||
runtimeDescendants(processes, identities)
|
||||
if err := e.signalRuntime(ctx, master, true); err != nil {
|
||||
return fmt.Errorf("stop openresty master: %w", err)
|
||||
}
|
||||
// A successful signal only acknowledges delivery, not process termination.
|
||||
// Never create a second master while the old master or workers remain.
|
||||
waitCtx, cancel := context.WithTimeout(ctx, runtimeShutdownTimeout)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(runtimeProcessPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
remaining, err := e.runtimeProcesses(waitCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(runtimeDescendants(remaining, identities)) == 0 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
return fmt.Errorf("waiting for openresty processes to exit before restart: %w", waitCtx.Err())
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
return e.startRuntime(ctx)
|
||||
}
|
||||
|
||||
func matchesRuntimeMaster(command, configPath string) bool {
|
||||
command = strings.ReplaceAll(command, "\x00", " ")
|
||||
if !strings.HasPrefix(command, "nginx: master process ") && !strings.HasPrefix(command, "openresty: master process ") {
|
||||
return false
|
||||
}
|
||||
fields := strings.Fields(command)
|
||||
for i := 0; i+1 < len(fields); i++ {
|
||||
if fields[i] == "-c" {
|
||||
return filepath.IsAbs(fields[i+1]) && filepath.Clean(fields[i+1]) == filepath.Clean(configPath)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func inspectOpenrestyProcesses(ctx context.Context, binary, configPath string) ([]runtimeProcess, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path, err := exec.LookPath(binary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
executable, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inspectProcRuntime("/proc", executable, binary, configPath, os.Geteuid())
|
||||
}
|
||||
|
||||
func signalOpenrestyProcess(ctx context.Context, binary, config string, process runtimeProcess, quit bool) error {
|
||||
processes, err := inspectOpenrestyProcesses(ctx, binary, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range processes {
|
||||
if current.PID == process.PID && current.Master && current.StartTime == process.StartTime {
|
||||
signal := syscall.SIGHUP
|
||||
if quit {
|
||||
signal = syscall.SIGQUIT
|
||||
}
|
||||
return syscall.Kill(current.PID, signal)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("openresty master identity changed before signalling pid %d", process.PID)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build !unix
|
||||
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func inspectOpenrestyProcesses(_ context.Context, _, _ string) ([]runtimeProcess, error) {
|
||||
return nil, errors.New("safe openresty process recovery is unsupported on this platform; refusing an unverified start")
|
||||
}
|
||||
|
||||
func signalOpenrestyProcess(_ context.Context, _, _ string, _ runtimeProcess, _ bool) error {
|
||||
return errors.New("safe openresty process signalling is unsupported on this platform")
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//nolint:unused // Used by the Linux process inspector.
|
||||
const procParentPIDIndex = 1
|
||||
|
||||
//nolint:unused // Used by the Linux process inspector.
|
||||
const procStartTimeIndex = 19 // Field 22, counting from state after the process name.
|
||||
|
||||
//nolint:unused // Used by the Linux executor; portable for process fixture tests.
|
||||
func inspectProcRuntime(root string, executable os.FileInfo, binary, configPath string, uid int) ([]runtimeProcess, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var processes []runtimeProcess
|
||||
for _, entry := range entries {
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil || pid <= 0 || !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
process, found, err := inspectProcProcess(filepath.Join(root, entry.Name()), pid, executable, binary, configPath, uid)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
//nolint:unused // Used by the Linux process inspector.
|
||||
func inspectProcProcess(dir string, pid int, executable os.FileInfo, binary, configPath string, uid int) (runtimeProcess, bool, error) {
|
||||
//nolint:gosec // Read a fixed proc entry under a numeric PID directory.
|
||||
status, err := os.ReadFile(filepath.Join(dir, "status"))
|
||||
if err != nil {
|
||||
return runtimeProcess{}, false, err
|
||||
}
|
||||
owned := effectiveProcessUID(string(status)) == uid
|
||||
//nolint:gosec // Read a fixed proc entry under a numeric PID directory.
|
||||
command, err := os.ReadFile(filepath.Join(dir, "cmdline"))
|
||||
if err != nil {
|
||||
return runtimeProcess{}, false, err
|
||||
}
|
||||
title := strings.ReplaceAll(string(command), "\x00", " ")
|
||||
if !strings.HasPrefix(title, "nginx: ") && !strings.HasPrefix(title, "openresty: ") {
|
||||
return runtimeProcess{}, false, nil
|
||||
}
|
||||
master := owned && matchesRuntimeMaster(title, configPath)
|
||||
exe, err := os.Stat(filepath.Join(dir, "exe"))
|
||||
if err != nil {
|
||||
if !os.IsPermission(err) {
|
||||
return runtimeProcess{}, false, err
|
||||
}
|
||||
// Non-master candidates are only retained by runtimeDescendants when
|
||||
// their parent belongs to this instance. A capability-enabled non-root
|
||||
// master may be non-dumpable, making /proc/PID/exe inaccessible; verify
|
||||
// its invocation and UID instead.
|
||||
fields := strings.Fields(title)
|
||||
if master && (len(fields) < 4 || fields[3] != binary) {
|
||||
return runtimeProcess{}, false, fmt.Errorf("cannot verify executable of openresty master %d", pid)
|
||||
}
|
||||
} else if !os.SameFile(executable, exe) {
|
||||
if master {
|
||||
return runtimeProcess{}, false, fmt.Errorf("executable changed for openresty master %d; refusing duplicate start", pid)
|
||||
}
|
||||
return runtimeProcess{}, false, nil
|
||||
}
|
||||
//nolint:gosec // Read a fixed proc entry under a numeric PID directory.
|
||||
stat, err := os.ReadFile(filepath.Join(dir, "stat"))
|
||||
if err != nil {
|
||||
return runtimeProcess{}, false, err
|
||||
}
|
||||
closeParen := strings.LastIndexByte(string(stat), ')')
|
||||
if closeParen < 0 {
|
||||
return runtimeProcess{}, false, fmt.Errorf("invalid stat for process %d", pid)
|
||||
}
|
||||
fields := strings.Fields(string(stat)[closeParen+1:])
|
||||
if len(fields) <= procStartTimeIndex {
|
||||
return runtimeProcess{}, false, fmt.Errorf("incomplete stat for process %d", pid)
|
||||
}
|
||||
if fields[0] == "Z" {
|
||||
return runtimeProcess{}, false, nil
|
||||
}
|
||||
parentPID, err := strconv.Atoi(fields[procParentPIDIndex])
|
||||
if err != nil {
|
||||
return runtimeProcess{}, false, fmt.Errorf("invalid parent pid for process %d: %w", pid, err)
|
||||
}
|
||||
return runtimeProcess{PID: pid, ParentPID: parentPID, Master: master, StartTime: fields[procStartTimeIndex]}, true, nil
|
||||
}
|
||||
|
||||
//nolint:unused // Used by the Linux process inspector.
|
||||
func effectiveProcessUID(status string) int {
|
||||
for _, line := range strings.Split(status, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[0] == "Uid:" {
|
||||
uid, err := strconv.Atoi(fields[2])
|
||||
if err == nil {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInspectProcRuntime(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
binary := filepath.Join(root, "openresty")
|
||||
if err := os.WriteFile(binary, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable, err := os.Stat(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := filepath.Join(root, "nginx.conf")
|
||||
writeProcFixture(t, root, 42, 1, 1000, "nginx: master process "+binary+" -c "+config, "S", binary)
|
||||
// Root masters may run workers as a different user. Track them during shutdown.
|
||||
writeProcFixture(t, root, 43, 42, 1001, "nginx: worker process", "S", binary)
|
||||
writeProcFixture(t, root, 44, 42, 1000, "nginx: worker process", "Z", binary)
|
||||
writeProcFixture(t, root, 45, 1, 1000, "unrelated command", "S", binary)
|
||||
// Another instance using the same executable must not block this instance.
|
||||
writeProcFixture(t, root, 48, 1, 1000, "nginx: master process "+binary+" -c /other/nginx.conf", "S", binary)
|
||||
writeProcFixture(t, root, 49, 48, 1000, "nginx: worker process", "S", binary)
|
||||
otherBinary := filepath.Join(root, "other")
|
||||
if err := os.WriteFile(otherBinary, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeProcFixture(t, root, 46, 1, 1000, "nginx: worker process", "S", otherBinary)
|
||||
processes, err := inspectProcRuntime(root, executable, binary, config, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
processes = runtimeDescendants(processes, runtimeMasterIdentities(processes))
|
||||
if len(processes) != 2 {
|
||||
t.Fatalf("inspected processes = %v, want live master and worker", processes)
|
||||
}
|
||||
if processes[0] != (runtimeProcess{PID: 42, ParentPID: 1, Master: true, StartTime: "142"}) {
|
||||
t.Errorf("master identity = %v, want PID 42 and start time 142", processes[0])
|
||||
}
|
||||
if processes[1].PID != 43 || processes[1].Master {
|
||||
t.Errorf("worker identity = %v, want worker 43", processes[1])
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(root, "42")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remaining, err := inspectProcRuntime(root, executable, binary, config, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if master, err := findRuntimeMaster(remaining); err != nil || master.PID != 0 {
|
||||
t.Errorf("unattributed worker blocks cold start: master=%v err=%v", master, err)
|
||||
}
|
||||
writeProcFixture(t, root, 47, 1, 1000, "nginx: master process "+binary+" -c "+config, "S", otherBinary)
|
||||
if _, err := inspectProcRuntime(root, executable, binary, config, 1000); err == nil {
|
||||
t.Error("inspectProcRuntime(replaced executable) = nil, want refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectProcRuntimeIgnoresDifferentConfigOrOwner(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
uid int
|
||||
config string
|
||||
}{
|
||||
{name: "different config", uid: 1000, config: "/other/nginx.conf"},
|
||||
{name: "different owner", uid: 1001, config: "/data/nginx.conf"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
binary := filepath.Join(root, "openresty")
|
||||
if err := os.WriteFile(binary, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable, err := os.Stat(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeProcFixture(t, root, 42, 1, tc.uid, "nginx: master process "+binary+" -c "+tc.config, "S", binary)
|
||||
processes, err := inspectProcRuntime(root, executable, binary, "/data/nginx.conf", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if master, err := findRuntimeMaster(processes); err != nil || master.PID != 0 {
|
||||
t.Errorf("unrelated master blocks cold start: master=%v err=%v", master, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeProcFixture(t *testing.T, root string, pid, parentPID, uid int, title, state, binary string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, strconv.Itoa(pid))
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields := make([]string, 20)
|
||||
for i := range fields {
|
||||
fields[i] = "0"
|
||||
}
|
||||
fields[0] = state
|
||||
fields[procParentPIDIndex] = strconv.Itoa(parentPID)
|
||||
fields[19] = strconv.Itoa(pid + 100)
|
||||
for name, content := range map[string]string{
|
||||
"status": fmt.Sprintf("Uid:\t%d\t%d\t%d\t%d\n", uid, uid, uid, uid),
|
||||
"cmdline": title + "\x00",
|
||||
"stat": fmt.Sprintf("%d (nginx worker) %s\n", pid, strings.Join(fields, " ")),
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Symlink(binary, filepath.Join(dir, "exe")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectProcRuntimeScopesUnreadableExecutables(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root can read the permission-denied fixture")
|
||||
}
|
||||
root := t.TempDir()
|
||||
binary := filepath.Join(root, "openresty")
|
||||
if err := os.WriteFile(binary, nil, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable, err := os.Stat(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := filepath.Join(root, "denied")
|
||||
if err := os.Mkdir(denied, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inaccessible := filepath.Join(denied, "exe")
|
||||
if err := os.Symlink(binary, inaccessible); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := "/data/nginx.conf"
|
||||
writeProcFixture(t, root, 42, 1, 1000, "nginx: master process "+binary+" -c "+config, "S", inaccessible)
|
||||
writeProcFixture(t, root, 43, 42, 1001, "nginx: worker process", "S", inaccessible)
|
||||
writeProcFixture(t, root, 50, 1, 1000, "nginx: master process "+binary+" -c /other/nginx.conf", "S", inaccessible)
|
||||
writeProcFixture(t, root, 51, 50, 1001, "nginx: worker process", "S", inaccessible)
|
||||
if err := os.Chmod(denied, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chmod(denied, 0o700); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if _, err := os.Stat(filepath.Join(root, "51", "exe")); !os.IsPermission(err) {
|
||||
t.Fatalf("fixture exe read = %v, want permission denied", err)
|
||||
}
|
||||
processes, err := inspectProcRuntime(root, executable, binary, config, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
processes = runtimeDescendants(processes, runtimeMasterIdentities(processes))
|
||||
if len(processes) != 2 || processes[0].PID != 42 || processes[1].PID != 43 {
|
||||
t.Fatalf("instance processes = %v, want own master and worker", processes)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//nolint:unused // Used by the non-Linux Unix inspector.
|
||||
const (
|
||||
psCommandIndex = 8
|
||||
psMasterBinaryIndex = 11
|
||||
)
|
||||
|
||||
//nolint:unused // Used by the non-Linux Unix inspector; portable for fixture tests.
|
||||
func parsePSRuntime(output, binary, configPath string, owner int) ([]runtimeProcess, error) {
|
||||
var processes []runtimeProcess
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) <= psCommandIndex {
|
||||
continue
|
||||
}
|
||||
uid, err := strconv.Atoi(fields[2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
title := strings.Join(fields[psCommandIndex:], " ")
|
||||
if !strings.HasPrefix(title, "nginx: ") && !strings.HasPrefix(title, "openresty: ") {
|
||||
continue
|
||||
}
|
||||
master := uid == owner && matchesRuntimeMaster(title, configPath)
|
||||
if master && (len(fields) <= psMasterBinaryIndex || fields[psMasterBinaryIndex] != binary) {
|
||||
return nil, fmt.Errorf("cannot verify openresty master invocation %s", fields[0])
|
||||
}
|
||||
pid, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentPID, err := strconv.Atoi(fields[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
processes = append(processes, runtimeProcess{PID: pid, ParentPID: parentPID, Master: master, StartTime: strings.Join(fields[3:psCommandIndex], " ")})
|
||||
}
|
||||
return processes, nil
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParsePSRuntimeInstanceMembership(t *testing.T) {
|
||||
output := `42 1 1000 Wed Oct 7 16:00:00 2026 nginx: master process openresty -c /data/nginx.conf
|
||||
43 42 1001 Wed Oct 7 16:00:01 2026 nginx: worker process
|
||||
50 1 1000 Wed Oct 7 16:00:00 2026 nginx: master process openresty -c /other/nginx.conf
|
||||
51 50 1001 Wed Oct 7 16:00:01 2026 nginx: worker process`
|
||||
processes, err := parsePSRuntime(output, "openresty", "/data/nginx.conf", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
processes = runtimeDescendants(processes, runtimeMasterIdentities(processes))
|
||||
if len(processes) != 2 || processes[0].PID != 42 || processes[1].PID != 43 || processes[1].ParentPID != 42 {
|
||||
t.Fatalf("instance processes = %v, want master 42 and worker 43", processes)
|
||||
}
|
||||
if processes[0].StartTime != "Wed Oct 7 16:00:00 2026" {
|
||||
t.Errorf("master start time = %q", processes[0].StartTime)
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReloadRecoversLiveMasterWithEmptyPID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
config := filepath.Join(dir, "nginx.conf")
|
||||
pidPath := filepath.Join(dir, "nginx.pid")
|
||||
if err := os.WriteFile(config, []byte("pid "+pidPath+";\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(pidPath, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runner := &fakeRunner{runFn: func(_ string, _ ...string) ([]byte, error) {
|
||||
return []byte(`invalid PID number ""`), errors.New("exit status 1")
|
||||
}}
|
||||
master := runtimeProcess{PID: 42, Master: true, StartTime: "123"}
|
||||
var signalled runtimeProcess
|
||||
executor := &PathExecutor{Path: "openresty", ConfigPath: config, Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return []runtimeProcess{master}, nil },
|
||||
signalProcess: func(process runtimeProcess, quit bool) error {
|
||||
if quit {
|
||||
t.Error("Reload requested quit, want reload")
|
||||
}
|
||||
signalled = process
|
||||
return nil
|
||||
},
|
||||
}
|
||||
if err := executor.Reload(context.Background()); err != nil {
|
||||
t.Fatalf("Reload(empty PID, live master) = %v, want nil", err)
|
||||
}
|
||||
if signalled != master {
|
||||
t.Errorf("Reload signalled %v, want %v", signalled, master)
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Errorf("Reload commands = %v, want direct master signal and no start", runner.calls)
|
||||
}
|
||||
data, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "42\n" {
|
||||
t.Errorf("recovered PID = %q, want 42 newline", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadStartsWithUnrelatedWorkers(t *testing.T) {
|
||||
runner := &fakeRunner{runFn: func(_ string, _ ...string) ([]byte, error) { return []byte("invalid pid"), errors.New("failed") }}
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return []runtimeProcess{{PID: 99}}, nil },
|
||||
}
|
||||
if err := executor.Reload(context.Background()); err == nil || !strings.Contains(err.Error(), "start failed") {
|
||||
t.Errorf("Reload(unrelated workers) = %v, want attempted start", err)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Errorf("Reload commands = %v, want one start", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartWaitsForOldWorkersBeforeStart(t *testing.T) {
|
||||
inspections := 0
|
||||
quit := false
|
||||
runner := &fakeRunner{runFn: func(_ string, _ ...string) ([]byte, error) {
|
||||
if !quit || inspections < 3 {
|
||||
t.Errorf("start before shutdown completed: quit=%v inspections=%d", quit, inspections)
|
||||
}
|
||||
return nil, nil
|
||||
}}
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) {
|
||||
inspections++
|
||||
if inspections == 1 {
|
||||
return []runtimeProcess{{PID: 42, Master: true}, {PID: 43, ParentPID: 42}}, nil
|
||||
}
|
||||
if inspections == 2 {
|
||||
return []runtimeProcess{{PID: 43, ParentPID: 1}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
signalProcess: func(_ runtimeProcess, gracefulQuit bool) error { quit = gracefulQuit; return nil },
|
||||
}
|
||||
if err := executor.Restart(context.Background()); err != nil {
|
||||
t.Fatalf("Restart(draining workers) = %v, want nil", err)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Errorf("Restart commands=%v, want one start", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartCancellationNeverStartsSecondMaster(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) { return []runtimeProcess{{PID: 42, Master: true}}, nil },
|
||||
signalProcess: func(_ runtimeProcess, _ bool) error { return nil },
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := executor.Restart(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Restart(cancelled)=%v, want context.Canceled", err)
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Errorf("Restart(cancelled) commands=%v, want no start", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartTimeoutNeverStartsWhileWorkersRemain(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
quits := 0
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) {
|
||||
if quits == 0 {
|
||||
return []runtimeProcess{{PID: 42, Master: true}, {PID: 43, ParentPID: 42}}, nil
|
||||
}
|
||||
return []runtimeProcess{{PID: 43, ParentPID: 1}}, nil
|
||||
},
|
||||
signalProcess: func(_ runtimeProcess, quit bool) error {
|
||||
if quit {
|
||||
quits++
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := executor.Restart(ctx); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("Restart(draining worker, deadline) = %v, want deadline exceeded", err)
|
||||
}
|
||||
if quits != 1 || len(runner.calls) != 0 {
|
||||
t.Errorf("Restart quit count=%d commands=%v, want one quit and no start", quits, runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadRefusesAmbiguousMasters(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) {
|
||||
return []runtimeProcess{{PID: 42, Master: true}, {PID: 43, Master: true}}, nil
|
||||
},
|
||||
}
|
||||
if err := executor.Reload(context.Background()); err == nil {
|
||||
t.Error("Reload(two masters) = nil, want refusal")
|
||||
}
|
||||
if len(runner.calls) != 0 {
|
||||
t.Errorf("Reload commands = %v, want no start", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type blockingRuntimeExecutor struct {
|
||||
fakeExecutor
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
restarted chan struct{}
|
||||
}
|
||||
|
||||
func (e *blockingRuntimeExecutor) Reload(context.Context) error {
|
||||
close(e.entered)
|
||||
<-e.release
|
||||
return nil
|
||||
}
|
||||
func (e *blockingRuntimeExecutor) Restart(context.Context) error { close(e.restarted); return nil }
|
||||
|
||||
func TestManagerApplySerializesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
executor := &blockingRuntimeExecutor{entered: make(chan struct{}), release: make(chan struct{}), restarted: make(chan struct{})}
|
||||
manager := &Manager{MainConfigPath: filepath.Join(dir, "etc", "nginx.conf"), RouteConfigPath: filepath.Join(dir, "etc", "routes.conf"), Executor: executor}
|
||||
applied := make(chan ApplyOutcome, 1)
|
||||
go func() { applied <- manager.Apply(context.Background(), "events {}\nhttp {}", "", nil) }()
|
||||
select {
|
||||
case <-executor.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Apply did not reach reload")
|
||||
}
|
||||
restarted := make(chan error, 1)
|
||||
go func() { restarted <- manager.Restart(context.Background()) }()
|
||||
select {
|
||||
case <-executor.restarted:
|
||||
t.Error("Restart overlapped Apply, want serialized lifecycle")
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
close(executor.release)
|
||||
if result := <-applied; result.Status != ApplyStatusSuccess {
|
||||
t.Errorf("Apply status=%v message=%s, want success", result.Status, result.Message)
|
||||
}
|
||||
if err := <-restarted; err != nil {
|
||||
t.Errorf("Restart(after Apply)=%v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairPIDRefusesRelativeOrMissingPath(t *testing.T) {
|
||||
config := filepath.Join(t.TempDir(), "nginx.conf")
|
||||
if err := os.WriteFile(config, []byte("pid logs/nginx.pid;"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executor := &PathExecutor{ConfigPath: config}
|
||||
if _, err := executor.repairPIDFile(42); err == nil || !strings.Contains(err.Error(), "absolute") {
|
||||
t.Errorf("repairPIDFile(relative path)=%v, want absolute path error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeDescendantsTracksOnlyInstanceIdentities(t *testing.T) {
|
||||
processes := []runtimeProcess{
|
||||
{PID: 40, ParentPID: 43, StartTime: "140"}, // Descendant precedes its parent.
|
||||
{PID: 42, Master: true, StartTime: "142"},
|
||||
{PID: 43, ParentPID: 42, StartTime: "143"},
|
||||
{PID: 50, StartTime: "150"}, // Unrelated master and worker.
|
||||
{PID: 51, ParentPID: 50, StartTime: "151"},
|
||||
}
|
||||
identities := runtimeMasterIdentities(processes)
|
||||
if got := runtimeDescendants(processes, identities); len(got) != 3 {
|
||||
t.Fatalf("instance processes = %v, want master and two descendants", got)
|
||||
}
|
||||
remaining := []runtimeProcess{
|
||||
{PID: 43, ParentPID: 1, StartTime: "143"}, // Reparented old worker.
|
||||
{PID: 40, ParentPID: 1, StartTime: "240"}, // Reused PID.
|
||||
{PID: 50, StartTime: "150"},
|
||||
{PID: 51, ParentPID: 50, StartTime: "151"},
|
||||
}
|
||||
got := runtimeDescendants(remaining, identities)
|
||||
if len(got) != 1 || got[0].PID != 43 {
|
||||
t.Fatalf("remaining instance processes = %v, want old worker 43", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartIgnoresUnrelatedProcessesAfterShutdown(t *testing.T) {
|
||||
inspections := 0
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{Path: "openresty", Runner: runner,
|
||||
inspectProcesses: func() ([]runtimeProcess, error) {
|
||||
inspections++
|
||||
processes := []runtimeProcess{{PID: 50}, {PID: 51, ParentPID: 50}}
|
||||
if inspections == 1 {
|
||||
processes = append(processes, runtimeProcess{PID: 42, Master: true})
|
||||
}
|
||||
return processes, nil
|
||||
},
|
||||
signalProcess: func(_ runtimeProcess, _ bool) error { return nil },
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := executor.Restart(ctx); err != nil {
|
||||
t.Fatalf("Restart(other nginx instance) = %v, want nil", err)
|
||||
}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Errorf("Restart commands = %v, want one start", runner.calls)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//go:build unix && !linux
|
||||
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Non-Linux Unix systems expose process ownership and start time through ps.
|
||||
func inspectOpenrestyProcesses(ctx context.Context, binary, configPath string) ([]runtimeProcess, error) {
|
||||
output, err := exec.CommandContext(ctx, "ps", "-axo", "pid=,ppid=,uid=,lstart=,command=").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect openresty processes: %w", err)
|
||||
}
|
||||
return parsePSRuntime(string(output), binary, configPath, os.Geteuid())
|
||||
}
|
||||
|
||||
func signalOpenrestyProcess(ctx context.Context, binary, config string, process runtimeProcess, quit bool) error {
|
||||
processes, err := inspectOpenrestyProcesses(ctx, binary, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, current := range processes {
|
||||
if current == process && current.Master {
|
||||
signal := syscall.SIGHUP
|
||||
if quit {
|
||||
signal = syscall.SIGQUIT
|
||||
}
|
||||
return syscall.Kill(current.PID, signal)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("openresty master identity changed before signalling pid %d", process.PID)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func (s *Service) applyRenderedConfig(ctx context.Context, mode string, snapshot
|
||||
}
|
||||
if !shouldReportApplyLog(alreadySynced, applyResult.reportResult) {
|
||||
slog.Debug("skipping duplicate apply log report", "version", config.Version, "checksum", config.Checksum, "result", applyResult.reportResult)
|
||||
if applyResult.reportResult != ApplyResultSuccess {
|
||||
if applyResult.reportResult == ApplyResultFailed {
|
||||
return outcomeError(config.Version, applyResult.message)
|
||||
}
|
||||
if err := s.syncReferencedWAFIPGroups(ctx, rendered.supportFiles); err != nil {
|
||||
@@ -215,7 +215,7 @@ func (s *Service) applyRenderedConfig(ctx context.Context, mode string, snapshot
|
||||
slog.Error("report apply log failed", "version", config.Version, "result", applyResult.reportResult, "error", err)
|
||||
return err
|
||||
}
|
||||
if applyResult.reportResult != ApplyResultSuccess {
|
||||
if applyResult.reportResult == ApplyResultFailed {
|
||||
slog.Warn("failed apply log reported", "version", config.Version)
|
||||
return outcomeError(config.Version, applyResult.message)
|
||||
}
|
||||
|
||||
@@ -732,8 +732,8 @@ func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) {
|
||||
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{
|
||||
Version: client.config.Version,
|
||||
Checksum: client.config.Checksum,
|
||||
}); err == nil {
|
||||
t.Fatal("SyncOnce(rolled-back target) returned nil, want an error so automatic retry continues")
|
||||
}); err != nil {
|
||||
t.Fatalf("expected warning outcome to keep sync successful, got %v", err)
|
||||
}
|
||||
|
||||
snapshot, err := stateStore.Load()
|
||||
|
||||
@@ -262,7 +262,7 @@ func oidcDiscoveryResponse() *http.Response {
|
||||
}
|
||||
|
||||
type mockClaims struct {
|
||||
ID any `json:"id"`
|
||||
ID uint64 `json:"id"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
@@ -275,16 +275,12 @@ type mockClaims struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// generateMockIDToken signs test claims with numeric IDs or Casdoor-style string IDs.
|
||||
func generateMockIDToken(issuer, sub, aud, nonce, username, email, name string) string {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: testRSAPrivateKey}, (&jose.SignerOptions{}).WithType("JWT"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var id any = sub
|
||||
if numericID, parseErr := strconv.ParseUint(sub, 10, 64); parseErr == nil {
|
||||
id = numericID
|
||||
}
|
||||
id, _ := strconv.ParseUint(sub, 10, 64)
|
||||
claims := mockClaims{
|
||||
ID: id,
|
||||
Issuer: issuer,
|
||||
@@ -709,7 +705,6 @@ func TestLogout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallbackLoginAndUserInfo covers string provider IDs, repeat logins, and local ID collisions.
|
||||
func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
initializeTestConfig()
|
||||
dbConn := setupTestDB(t)
|
||||
@@ -724,8 +719,7 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
var state string
|
||||
|
||||
// 1. Mock the outgoing HTTP client for token exchange and user info fetching
|
||||
const externalSubject = "a27dfc56-07ae-4c9d-9e5c-99103bd8805f"
|
||||
httpMock := newMockOIDCClient(testIssuerURL, testClientID, &state, externalSubject, "test_oauth_user", "oauth@linux.do", "Oauth Test User")
|
||||
httpMock := newMockOIDCClient(testIssuerURL, testClientID, &state, "88888", "test_oauth_user", "oauth@linux.do", "Oauth Test User")
|
||||
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
||||
|
||||
// Get Login URL first to initialize the session and generate the state
|
||||
@@ -772,25 +766,15 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
t.Errorf("expected logged_in status, got %s", callbackResp.Data.Status)
|
||||
}
|
||||
|
||||
if callbackResp.Data.User == nil {
|
||||
t.Fatal("Callback(Casdoor string ID) returned no user, want logged-in user")
|
||||
}
|
||||
if callbackResp.Data.User.Username != "test_oauth_user" || callbackResp.Data.User.ID == 0 {
|
||||
if callbackResp.Data.User.Username != "test_oauth_user" || callbackResp.Data.User.ID != 88888 {
|
||||
t.Errorf("unexpected user returned: %+v", callbackResp.Data.User)
|
||||
}
|
||||
|
||||
// Verify user is created in database
|
||||
var user model.User
|
||||
if err := dbConn.First(&user, "id = ?", callbackResp.Data.User.ID).Error; err != nil {
|
||||
if err := dbConn.First(&user, "id = ?", 88888).Error; err != nil {
|
||||
t.Fatalf("user was not created in DB: %v", err)
|
||||
}
|
||||
account, err := repository.FindExternalAccount(context.Background(), 100, externalSubject)
|
||||
if err != nil {
|
||||
t.Fatalf("FindExternalAccount(%q) error = %v, want nil", externalSubject, err)
|
||||
}
|
||||
if account.UserID != user.ID {
|
||||
t.Errorf("FindExternalAccount(%q).UserID = %d, want %d", externalSubject, account.UserID, user.ID)
|
||||
}
|
||||
|
||||
// Extract session cookie
|
||||
cookies := w.Result().Cookies()
|
||||
@@ -811,40 +795,8 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
t.Fatalf("failed to fetch user info, status %d", w2.Code)
|
||||
}
|
||||
|
||||
// The same external subject must reuse its binding on subsequent logins.
|
||||
wRepeatLogin := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source="+testSourceName, nil, nil, []*http.Cookie{sessionCookie})
|
||||
if wRepeatLogin.Code != http.StatusOK {
|
||||
t.Fatalf("GetLoginURL(existing user) status = %d, want 200", wRepeatLogin.Code)
|
||||
}
|
||||
if err := json.Unmarshal(wRepeatLogin.Body.Bytes(), &loginUrlResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedURL, err = url.Parse(loginUrlResp.Data.AuthorizeURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state = parsedURL.Query().Get("state")
|
||||
wRepeat := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(fmt.Sprintf(`{"state":"%s","code":"repeat_auth_code"}`, state)), map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
}, wRepeatLogin.Result().Cookies())
|
||||
if wRepeat.Code != http.StatusOK {
|
||||
t.Fatalf("Callback(existing Casdoor user) status = %d, want 200; body: %s", wRepeat.Code, wRepeat.Body.String())
|
||||
}
|
||||
var repeatResp struct {
|
||||
Data OAuthCallbackResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(wRepeat.Body.Bytes(), &repeatResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repeatResp.Data.User == nil || repeatResp.Data.User.ID != user.ID {
|
||||
t.Errorf("Callback(existing Casdoor user) user = %+v, want ID %d", repeatResp.Data.User, user.ID)
|
||||
}
|
||||
|
||||
// 5. Test Callback (Login flow - existing user, username collision check)
|
||||
var state2 string
|
||||
if err := dbConn.Create(&model.User{ID: 99999, Username: "local_user", IsActive: true}).Error; err != nil {
|
||||
t.Fatalf("failed to create local user with overlapping external ID: %v", err)
|
||||
}
|
||||
// Callback with same username but different external ID (99999)
|
||||
httpMock2 := newMockOIDCClient(testIssuerURL, testClientID, &state2, "99999", "test_oauth_user", "another@linux.do", "Another User")
|
||||
|
||||
@@ -881,9 +833,6 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
Data OAuthCallbackResult `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(w3.Body.Bytes(), &collisionResp)
|
||||
if collisionResp.Data.User == nil || collisionResp.Data.User.ID == 0 || collisionResp.Data.User.ID == 99999 {
|
||||
t.Fatalf("Callback(numeric provider ID) user = %+v, want independent local ID", collisionResp.Data.User)
|
||||
}
|
||||
|
||||
if collisionResp.Data.User.Username != "test_oauth_user-1" {
|
||||
t.Errorf("expected collision renamed username, got %s", collisionResp.Data.User.Username)
|
||||
@@ -942,80 +891,6 @@ func TestCallbackLoginAndUserInfo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestCallbackIdentityIsScopedToAuthSource verifies legacy IDs survive login and
|
||||
// identical subjects from different authentication sources remain separate users.
|
||||
func TestCallbackIdentityIsScopedToAuthSource(t *testing.T) {
|
||||
initializeTestConfig()
|
||||
dbConn := setupTestDB(t)
|
||||
mockRedis := newMockRedisClient()
|
||||
seedTestAuthSource(t, dbConn)
|
||||
require.NoError(t, dbConn.Create(&model.SystemConfig{
|
||||
Key: model.ConfigKeyRegistrationEnabled, Value: "true", Type: "system",
|
||||
}).Error)
|
||||
require.NoError(t, dbConn.Create(&model.User{
|
||||
ID: 88888, Username: "legacy_user", IsActive: true,
|
||||
}).Error)
|
||||
require.NoError(t, dbConn.Create(&model.ExternalAccount{
|
||||
ID: 1, AuthSourceID: 100, UserID: 88888, ExternalID: "88888",
|
||||
}).Error)
|
||||
require.NoError(t, dbConn.Create(&model.AuthSource{
|
||||
ID: 101, Name: "second-source", Type: model.AuthSourceTypeOIDC,
|
||||
IsActive: true, ClientID: testClientID, ClientSecret: testClientSecret,
|
||||
OpenIDDiscoveryURL: testIssuerURL,
|
||||
}).Error)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sourceID uint64
|
||||
legacy bool
|
||||
}{
|
||||
{name: testSourceName, sourceID: 100, legacy: true},
|
||||
{name: "second-source", sourceID: 101},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var state string
|
||||
httpMock := newMockOIDCClient(testIssuerURL, testClientID, &state, "88888", "provider_user", "provider@example.com", "Provider User")
|
||||
router := setupTestRouter(dbConn, mockRedis, httpMock)
|
||||
login := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source="+tc.name, nil, nil, nil)
|
||||
require.Equal(t, http.StatusOK, login.Code, login.Body.String())
|
||||
var loginResp struct {
|
||||
Data OAuthAuthorizeResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(login.Body.Bytes(), &loginResp))
|
||||
authorizeURL, err := url.Parse(loginResp.Data.AuthorizeURL)
|
||||
require.NoError(t, err)
|
||||
state = authorizeURL.Query().Get("state")
|
||||
require.NotEmpty(t, state)
|
||||
|
||||
callback := performRequest(router, http.MethodPost, "/api/v1/oauth/callback",
|
||||
[]byte(fmt.Sprintf(`{"state":"%s","code":"test_code"}`, state)),
|
||||
map[string]string{"Content-Type": "application/json"}, login.Result().Cookies())
|
||||
require.Equal(t, http.StatusOK, callback.Code, callback.Body.String())
|
||||
var callbackResp struct {
|
||||
Data OAuthCallbackResult `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(callback.Body.Bytes(), &callbackResp))
|
||||
require.Equal(t, "logged_in", callbackResp.Data.Status)
|
||||
require.NotNil(t, callbackResp.Data.User)
|
||||
if tc.legacy {
|
||||
assert.Equal(t, uint64(88888), callbackResp.Data.User.ID)
|
||||
assert.Equal(t, "legacy_user", callbackResp.Data.User.Username)
|
||||
} else {
|
||||
assert.NotZero(t, callbackResp.Data.User.ID)
|
||||
assert.NotEqual(t, uint64(88888), callbackResp.Data.User.ID)
|
||||
}
|
||||
account, err := repository.FindExternalAccount(context.Background(), tc.sourceID, "88888")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, callbackResp.Data.User.ID, account.UserID)
|
||||
})
|
||||
}
|
||||
var users, accounts int64
|
||||
require.NoError(t, dbConn.Model(&model.User{}).Count(&users).Error)
|
||||
require.NoError(t, dbConn.Model(&model.ExternalAccount{}).Count(&accounts).Error)
|
||||
assert.Equal(t, int64(2), users)
|
||||
assert.Equal(t, int64(2), accounts)
|
||||
}
|
||||
|
||||
func TestCallbackBind(t *testing.T) {
|
||||
initializeTestConfig()
|
||||
dbConn := setupTestDB(t)
|
||||
|
||||
@@ -59,9 +59,9 @@ type Client interface {
|
||||
VerifyToken(context.Context) error
|
||||
FindZone(context.Context, string) (*Zone, error)
|
||||
GetRecord(context.Context, string, string) (*DNSRecord, error)
|
||||
ListRecords(context.Context, string, string, string) ([]DNSRecord, error)
|
||||
CreateRecord(context.Context, string, RecordInput) (*DNSRecord, error)
|
||||
UpdateRecord(context.Context, string, string, RecordInput) (*DNSRecord, error)
|
||||
ListARecords(context.Context, string, string) ([]DNSRecord, error)
|
||||
CreateARecord(context.Context, string, RecordInput) (*DNSRecord, error)
|
||||
UpdateARecord(context.Context, string, string, RecordInput) (*DNSRecord, error)
|
||||
DeleteRecord(context.Context, string, string) error
|
||||
}
|
||||
|
||||
@@ -146,9 +146,9 @@ func (client *HTTPClient) GetRecord(ctx context.Context, zoneID, recordID string
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// ListRecords lists exact-name DNS records of a type.
|
||||
func (client *HTTPClient) ListRecords(ctx context.Context, zoneID, name, recordType string) ([]DNSRecord, error) {
|
||||
query := url.Values{"type": {strings.TrimSpace(recordType)}, "name": {strings.TrimSpace(name)}, "per_page": {"100"}}
|
||||
// ListARecords lists exact-name A records.
|
||||
func (client *HTTPClient) ListARecords(ctx context.Context, zoneID, name string) ([]DNSRecord, error) {
|
||||
query := url.Values{"type": {"A"}, "name": {strings.TrimSpace(name)}, "per_page": {"100"}}
|
||||
var records []DNSRecord
|
||||
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records"
|
||||
if err := client.do(ctx, http.MethodGet, path, query, nil, &records); err != nil {
|
||||
@@ -157,8 +157,8 @@ func (client *HTTPClient) ListRecords(ctx context.Context, zoneID, name, recordT
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// CreateRecord creates a DNS record.
|
||||
func (client *HTTPClient) CreateRecord(ctx context.Context, zoneID string, input RecordInput) (*DNSRecord, error) {
|
||||
// CreateARecord creates an A record.
|
||||
func (client *HTTPClient) CreateARecord(ctx context.Context, zoneID string, input RecordInput) (*DNSRecord, error) {
|
||||
var record DNSRecord
|
||||
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records"
|
||||
if err := client.do(ctx, http.MethodPost, path, nil, input, &record); err != nil {
|
||||
@@ -167,8 +167,8 @@ func (client *HTTPClient) CreateRecord(ctx context.Context, zoneID string, input
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// UpdateRecord replaces a DNS record.
|
||||
func (client *HTTPClient) UpdateRecord(ctx context.Context, zoneID, recordID string, input RecordInput) (*DNSRecord, error) {
|
||||
// UpdateARecord replaces an A record.
|
||||
func (client *HTTPClient) UpdateARecord(ctx context.Context, zoneID, recordID string, input RecordInput) (*DNSRecord, error) {
|
||||
var record DNSRecord
|
||||
path := "/zones/" + url.PathEscape(zoneID) + "/dns_records/" + url.PathEscape(recordID)
|
||||
if err := client.do(ctx, http.MethodPut, path, nil, input, &record); err != nil {
|
||||
@@ -177,21 +177,6 @@ func (client *HTTPClient) UpdateRecord(ctx context.Context, zoneID, recordID str
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// ListARecords lists exact-name A records.
|
||||
func (client *HTTPClient) ListARecords(ctx context.Context, zoneID, name string) ([]DNSRecord, error) {
|
||||
return client.ListRecords(ctx, zoneID, name, "A")
|
||||
}
|
||||
|
||||
// CreateARecord creates an A record.
|
||||
func (client *HTTPClient) CreateARecord(ctx context.Context, zoneID string, input RecordInput) (*DNSRecord, error) {
|
||||
return client.CreateRecord(ctx, zoneID, input)
|
||||
}
|
||||
|
||||
// UpdateARecord replaces an A record.
|
||||
func (client *HTTPClient) UpdateARecord(ctx context.Context, zoneID, recordID string, input RecordInput) (*DNSRecord, error) {
|
||||
return client.UpdateRecord(ctx, zoneID, recordID, input)
|
||||
}
|
||||
|
||||
// DeleteRecord deletes a DNS record.
|
||||
func (client *HTTPClient) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
|
||||
var result struct {
|
||||
|
||||
@@ -11,18 +11,12 @@ const (
|
||||
errDNSAccountInvalid = "请选择有效的 Cloudflare DNS 账号"
|
||||
errGroupNameRequired = "分组名称不能为空"
|
||||
errGroupNodeSame = "主节点和备用节点不能相同"
|
||||
errGroupTargetModeInvalid = "请选择有效的指向方式"
|
||||
errRecordTypeInvalid = "请选择 CNAME、A 或 AAAA 记录类型"
|
||||
errRecordContentInvalid = "DNS 记录值无效,请检查域名或 IP 地址"
|
||||
errNodeInvalid = "请选择有效的边缘节点"
|
||||
errNodeIPv4Required = "生效节点必须配置合法 IPv4"
|
||||
errGroupDisabled = "指向分组已停用"
|
||||
errMemberExists = "该域名已加入其他指向分组"
|
||||
errMultipleDNSRecords = "检测到 Cloudflare 中存在多条同名 DNS 记录,请先手动清理"
|
||||
errMultipleARecords = "检测到 Cloudflare 中存在多条同名 A 记录,请先手动清理"
|
||||
errSyncFailed = "Cloudflare DNS 同步失败"
|
||||
errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败"
|
||||
errTaskDispatchFailed = "无法投递 Cloudflare 同步任务"
|
||||
errTargetGroupSame = "目标分组不能为当前分组"
|
||||
errTargetGroupInvalid = "目标分组不存在"
|
||||
errNoMembersSelected = "未选择任何成员"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -220,110 +219,30 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input
|
||||
if name == "" {
|
||||
return nil, errors.New(errGroupNameRequired)
|
||||
}
|
||||
targetMode := strings.TrimSpace(input.TargetMode)
|
||||
if targetMode == "" {
|
||||
targetMode = model.CFPointingTargetModeNode
|
||||
if input.BackupNodeID != nil && *input.BackupNodeID == input.PrimaryNodeID {
|
||||
return nil, errors.New(errGroupNodeSame)
|
||||
}
|
||||
if targetMode != model.CFPointingTargetModeNode && targetMode != model.CFPointingTargetModeCustom {
|
||||
return nil, errors.New(errGroupTargetModeInvalid)
|
||||
primary, err := validEdgeNode(ctx, input.PrimaryNodeID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
primaryNodeID := uint(0)
|
||||
var backupNodeID *uint
|
||||
recordType := strings.ToUpper(strings.TrimSpace(input.RecordType))
|
||||
if recordType == "" {
|
||||
recordType = "A"
|
||||
}
|
||||
recordContent := strings.TrimSpace(input.RecordContent)
|
||||
if targetMode == model.CFPointingTargetModeCustom && !validCustomRecordType(recordType) {
|
||||
return nil, errors.New(errRecordTypeInvalid)
|
||||
}
|
||||
if targetMode == model.CFPointingTargetModeCustom && !validCustomRecordContent(recordType, recordContent) {
|
||||
return nil, errors.New(errRecordContentInvalid)
|
||||
}
|
||||
if targetMode == model.CFPointingTargetModeNode {
|
||||
resolvedPrimary, resolvedBackup, err := resolveGroupNodes(ctx, input)
|
||||
if err != nil {
|
||||
if input.BackupNodeID != nil {
|
||||
if _, err = validEdgeNode(ctx, *input.BackupNodeID, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
primaryNodeID = resolvedPrimary
|
||||
backupNodeID = resolvedBackup
|
||||
}
|
||||
activeNodeID := primaryNodeID
|
||||
if existing != nil && targetMode == model.CFPointingTargetModeNode && existing.TargetMode == targetMode && existing.PrimaryNodeID == primaryNodeID && equalOptionalUint(existing.BackupNodeID, backupNodeID) {
|
||||
activeNodeID = existing.ActiveNodeID
|
||||
}
|
||||
if existing == nil {
|
||||
existing = &model.CFPointingGroup{}
|
||||
}
|
||||
existing.Name = name
|
||||
existing.TargetMode = targetMode
|
||||
existing.RecordType = recordType
|
||||
existing.RecordContent = recordContent
|
||||
existing.PrimaryNodeID = primaryNodeID
|
||||
existing.ActiveNodeID = activeNodeID
|
||||
existing.BackupNodeID = backupNodeID
|
||||
existing.PrimaryNodeID = primary.ID
|
||||
existing.ActiveNodeID = primary.ID
|
||||
existing.BackupNodeID = input.BackupNodeID
|
||||
existing.DefaultProxied = input.DefaultProxied
|
||||
existing.Enabled = input.Enabled
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func equalOptionalUint(left, right *uint) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func resolveGroupNodes(ctx context.Context, input GroupInput) (uint, *uint, error) {
|
||||
if input.BackupNodeID != nil && *input.BackupNodeID == input.PrimaryNodeID {
|
||||
return 0, nil, errors.New(errGroupNodeSame)
|
||||
}
|
||||
primary, err := validEdgeNode(ctx, input.PrimaryNodeID, true)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if input.BackupNodeID != nil {
|
||||
if _, err = validEdgeNode(ctx, *input.BackupNodeID, false); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
return primary.ID, input.BackupNodeID, nil
|
||||
}
|
||||
|
||||
func validCustomRecordType(recordType string) bool {
|
||||
return recordType == "CNAME" || recordType == "A" || recordType == "AAAA"
|
||||
}
|
||||
|
||||
func validCustomRecordContent(recordType, content string) bool {
|
||||
switch recordType {
|
||||
case "A":
|
||||
return isIPv4(content)
|
||||
case "AAAA":
|
||||
return isIPv6(content)
|
||||
case "CNAME":
|
||||
return validCNAMEContent(content)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var cnameHostPattern = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$`)
|
||||
|
||||
func isIPv4(content string) bool {
|
||||
ip := net.ParseIP(content)
|
||||
return ip != nil && ip.To4() != nil
|
||||
}
|
||||
|
||||
func isIPv6(content string) bool {
|
||||
ip := net.ParseIP(content)
|
||||
return ip != nil && ip.To4() == nil
|
||||
}
|
||||
|
||||
func validCNAMEContent(content string) bool {
|
||||
host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(content)), ".")
|
||||
return len(host) > 0 && len(host) <= 253 && net.ParseIP(host) == nil && cnameHostPattern.MatchString(host)
|
||||
}
|
||||
|
||||
func validEdgeNode(ctx context.Context, id uint, requireIPv4 bool) (*model.OpenFlareNode, error) {
|
||||
node, err := repository.GetOpenFlareNodeByID(ctx, id)
|
||||
if err != nil || node.NodeType != "edge_node" {
|
||||
@@ -335,117 +254,31 @@ func validEdgeNode(ctx context.Context, id uint, requireIPv4 bool) (*model.OpenF
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// CheckNodeFailover switches enabled node groups to healthy backup nodes and fails back after primary recovery.
|
||||
func CheckNodeFailover(ctx context.Context) (int, error) {
|
||||
groups, err := repository.ListCFPointingGroups(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now()
|
||||
changed := 0
|
||||
var dispatchErrors []error
|
||||
for i := range groups {
|
||||
group := &groups[i]
|
||||
if !group.Enabled || group.TargetMode == model.CFPointingTargetModeCustom || group.BackupNodeID == nil {
|
||||
continue
|
||||
}
|
||||
primary, primaryErr := repository.GetOpenFlareNodeByID(ctx, group.PrimaryNodeID)
|
||||
if primaryErr != nil && !errors.Is(primaryErr, gorm.ErrRecordNotFound) {
|
||||
return changed, primaryErr
|
||||
}
|
||||
primaryHealthy := primaryErr == nil && isNodeHealthyForFailover(primary, now)
|
||||
targetNodeID := group.ActiveNodeID
|
||||
if primaryHealthy {
|
||||
targetNodeID = group.PrimaryNodeID
|
||||
} else {
|
||||
backup, backupErr := repository.GetOpenFlareNodeByID(ctx, *group.BackupNodeID)
|
||||
if backupErr != nil && !errors.Is(backupErr, gorm.ErrRecordNotFound) {
|
||||
return changed, backupErr
|
||||
}
|
||||
if backupErr == nil && isNodeHealthyForFailover(backup, now) {
|
||||
targetNodeID = *group.BackupNodeID
|
||||
}
|
||||
}
|
||||
if targetNodeID != group.ActiveNodeID {
|
||||
if err = repository.UpdateCFPointingGroupTarget(ctx, group.ID, map[string]any{"active_node_id": targetNodeID}); err != nil {
|
||||
return changed, err
|
||||
}
|
||||
if err = repository.MarkCFPointingGroupMembersPending(ctx, group.ID); err != nil {
|
||||
return changed, err
|
||||
}
|
||||
changed++
|
||||
}
|
||||
pending, pendingErr := repository.HasPendingCFPointingGroupMembers(ctx, group.ID)
|
||||
if pendingErr != nil {
|
||||
return changed, pendingErr
|
||||
}
|
||||
if pending {
|
||||
if _, err = DispatchGroupSync(ctx, group.ID, "cloudflare_node_failover"); err != nil {
|
||||
logger.WarnF(ctx, "[Cloudflare] dispatch failover sync failed: group_id=%d error=%v", group.ID, err)
|
||||
dispatchErrors = append(dispatchErrors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed, errors.Join(dispatchErrors...)
|
||||
}
|
||||
|
||||
func isNodeHealthyForFailover(node *model.OpenFlareNode, now time.Time) bool {
|
||||
if node == nil || node.Status == "offline" || node.OpenrestyStatus == "unhealthy" || strings.TrimSpace(node.LastError) != "" {
|
||||
return false
|
||||
}
|
||||
return node.LastSeenAt != nil && now.Sub(*node.LastSeenAt) <= 60*time.Second
|
||||
}
|
||||
|
||||
func buildGroupItem(ctx context.Context, group *model.CFPointingGroup) (*GroupItem, error) {
|
||||
targetMode := group.TargetMode
|
||||
if targetMode == "" {
|
||||
targetMode = model.CFPointingTargetModeNode
|
||||
primary, err := repository.GetOpenFlareNodeByID(ctx, group.PrimaryNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
primary := (*model.OpenFlareNode)(nil)
|
||||
active := (*model.OpenFlareNode)(nil)
|
||||
var err error
|
||||
if targetMode == model.CFPointingTargetModeNode {
|
||||
primary, err = lookupGroupNode(ctx, group.ID, group.PrimaryNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
active, err = lookupGroupNode(ctx, group.ID, group.ActiveNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
active, err := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count, err := repository.CountCFPointingMembersByGroupID(ctx, group.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &GroupItem{ID: group.ID, Name: group.Name, TargetMode: targetMode, RecordType: group.RecordType, RecordContent: group.RecordContent, PrimaryNode: nodeOptionForID(group.PrimaryNodeID, primary), ActiveNode: nodeOptionForID(group.ActiveNodeID, active), DefaultProxied: group.DefaultProxied, Enabled: group.Enabled, MemberCount: count, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt}
|
||||
item := &GroupItem{ID: group.ID, Name: group.Name, PrimaryNode: nodeOption(primary), ActiveNode: nodeOption(active), DefaultProxied: group.DefaultProxied, Enabled: group.Enabled, MemberCount: count, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt}
|
||||
if group.BackupNodeID != nil {
|
||||
backup, backupErr := lookupGroupNode(ctx, group.ID, *group.BackupNodeID)
|
||||
backup, backupErr := repository.GetOpenFlareNodeByID(ctx, *group.BackupNodeID)
|
||||
if backupErr != nil {
|
||||
return nil, backupErr
|
||||
}
|
||||
option := nodeOptionForID(*group.BackupNodeID, backup)
|
||||
option := nodeOption(backup)
|
||||
item.BackupNode = &option
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func lookupGroupNode(ctx context.Context, groupID, nodeID uint) (*model.OpenFlareNode, error) {
|
||||
node, err := repository.GetOpenFlareNodeByID(ctx, nodeID)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.WarnF(ctx, "[Cloudflare] pointing group references missing node: group_id=%d node_id=%d", groupID, nodeID)
|
||||
return nil, nil
|
||||
}
|
||||
return node, err
|
||||
}
|
||||
|
||||
func nodeOptionForID(id uint, node *model.OpenFlareNode) NodeOption {
|
||||
if node == nil {
|
||||
return NodeOption{ID: id}
|
||||
}
|
||||
return nodeOption(node)
|
||||
}
|
||||
|
||||
func nodeOption(node *model.OpenFlareNode) NodeOption {
|
||||
return NodeOption{ID: node.ID, Name: node.Name, IP: node.IP}
|
||||
}
|
||||
@@ -526,7 +359,7 @@ func UpdateMember(ctx context.Context, groupID, memberID uint, input MemberUpdat
|
||||
return memberItem(member, domain), nil
|
||||
}
|
||||
|
||||
// RemoveMember deletes the managed remote DNS record before removing local state.
|
||||
// RemoveMember deletes the managed remote A record before removing local state.
|
||||
func RemoveMember(ctx context.Context, groupID, memberID uint) error {
|
||||
member, err := repository.GetCFPointingMember(ctx, groupID, memberID)
|
||||
if err != nil {
|
||||
@@ -538,155 +371,7 @@ func RemoveMember(ctx context.Context, groupID, memberID uint) error {
|
||||
return repository.DeleteCFPointingMember(ctx, member)
|
||||
}
|
||||
|
||||
// MoveMember transfers a member from sourceGroupID to targetGroupID.
|
||||
func MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error) {
|
||||
if targetGroupID == 0 || targetGroupID == sourceGroupID {
|
||||
return nil, errors.New(errTargetGroupSame)
|
||||
}
|
||||
targetGroup, err := repository.GetCFPointingGroup(ctx, targetGroupID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New(errTargetGroupInvalid)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
member.GroupID = targetGroupID
|
||||
member.SyncStatus = model.CFMemberSyncPending
|
||||
member.LastError = ""
|
||||
if err = repository.SaveCFPointingMember(ctx, member); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetGroup.Enabled {
|
||||
if _, err = DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); err != nil {
|
||||
logger.WarnF(ctx, "[Cloudflare] dispatch move sync failed: member_id=%d error=%v", member.ID, err)
|
||||
}
|
||||
}
|
||||
domain, err := repository.GetZoneDomainByID(ctx, member.ZoneDomainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return memberItem(member, domain), nil
|
||||
}
|
||||
|
||||
// BatchMoveMembers transfers multiple members from sourceGroupID to targetGroupID.
|
||||
func BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error {
|
||||
if len(input.MemberIDs) == 0 {
|
||||
return errors.New(errNoMembersSelected)
|
||||
}
|
||||
if input.TargetGroupID == 0 || input.TargetGroupID == sourceGroupID {
|
||||
return errors.New(errTargetGroupSame)
|
||||
}
|
||||
targetGroup, err := repository.GetCFPointingGroup(ctx, input.TargetGroupID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(errTargetGroupInvalid)
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, memberID := range uniqueIDs(input.MemberIDs) {
|
||||
member, getErr := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||
if getErr != nil {
|
||||
continue
|
||||
}
|
||||
member.GroupID = input.TargetGroupID
|
||||
member.SyncStatus = model.CFMemberSyncPending
|
||||
member.LastError = ""
|
||||
if saveErr := repository.SaveCFPointingMember(ctx, member); saveErr != nil {
|
||||
logger.ErrorF(ctx, "[Cloudflare] batch move save member failed: member_id=%d error=%v", memberID, saveErr)
|
||||
continue
|
||||
}
|
||||
if targetGroup.Enabled {
|
||||
if _, syncErr := DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); syncErr != nil {
|
||||
logger.WarnF(ctx, "[Cloudflare] dispatch batch move sync failed: member_id=%d error=%v", member.ID, syncErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchRemoveMembers deletes multiple members and their remote DNS records.
|
||||
func BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error {
|
||||
if len(input.MemberIDs) == 0 {
|
||||
return errors.New(errNoMembersSelected)
|
||||
}
|
||||
for _, memberID := range uniqueIDs(input.MemberIDs) {
|
||||
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if delErr := DeleteManagedRecord(ctx, member.ID); delErr != nil {
|
||||
logger.WarnF(ctx, "[Cloudflare] delete remote record failed during batch remove: member_id=%d error=%v", member.ID, delErr)
|
||||
}
|
||||
if err = repository.DeleteCFPointingMember(ctx, member); err != nil {
|
||||
logger.ErrorF(ctx, "[Cloudflare] delete member failed during batch remove: member_id=%d error=%v", member.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchEnableProxy enables orange-cloud proxy for selected members.
|
||||
func BatchEnableProxy(ctx context.Context, groupID uint, input MemberBatchProxyInput) error {
|
||||
if len(input.MemberIDs) == 0 {
|
||||
return errors.New(errNoMembersSelected)
|
||||
}
|
||||
group, err := repository.GetCFPointingGroup(ctx, groupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
for _, memberID := range uniqueIDs(input.MemberIDs) {
|
||||
member, getErr := repository.GetCFPointingMember(ctx, groupID, memberID)
|
||||
if getErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = getErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
member.Proxied = true
|
||||
member.SyncStatus = model.CFMemberSyncPending
|
||||
member.LastError = ""
|
||||
if saveErr := repository.SaveCFPointingMember(ctx, member); saveErr != nil {
|
||||
logger.ErrorF(ctx, "[Cloudflare] batch enable proxy save member failed: member_id=%d error=%v", memberID, saveErr)
|
||||
if firstErr == nil {
|
||||
firstErr = saveErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if group.Enabled {
|
||||
if _, syncErr := DispatchMemberSync(ctx, member.ID, "cloudflare_member_batch_proxy"); syncErr != nil {
|
||||
logger.WarnF(ctx, "[Cloudflare] dispatch batch enable proxy sync failed: member_id=%d error=%v", member.ID, syncErr)
|
||||
if firstErr == nil {
|
||||
firstErr = syncErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func uniqueIDs(ids []uint) []uint {
|
||||
if len(ids) == 0 {
|
||||
return ids
|
||||
}
|
||||
seen := make(map[uint]struct{}, len(ids))
|
||||
result := make([]uint, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; !exists {
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// DeleteGroup removes every managed remote DNS record and then local state.
|
||||
// DeleteGroup removes every managed remote A record and then local state.
|
||||
func DeleteGroup(ctx context.Context, groupID uint) error {
|
||||
if _, err := repository.GetCFPointingGroup(ctx, groupID); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
)
|
||||
|
||||
func TestCheckNodeFailoverContinuesAfterDispatchError(t *testing.T) {
|
||||
ctx, memberID := setupCloudflareLogicDB(t)
|
||||
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
group, err := repository.GetCFPointingGroup(ctx, member.GroupID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingGroup() error = %v", err)
|
||||
}
|
||||
|
||||
lastSeen := time.Now()
|
||||
backup := model.OpenFlareNode{Name: "backup", NodeID: "node-backup", NodeType: "edge_node", IP: "203.0.113.11", Status: "online", OpenrestyStatus: "healthy", LastSeenAt: &lastSeen}
|
||||
if err := db.DB(ctx).Create(&backup).Error; err != nil {
|
||||
t.Fatalf("Create(backup) error = %v", err)
|
||||
}
|
||||
if err := db.DB(ctx).Model(group).Update("backup_node_id", backup.ID).Error; err != nil {
|
||||
t.Fatalf("Update(group backup) error = %v", err)
|
||||
}
|
||||
|
||||
zoneDomain := model.ZoneDomain{ZoneID: 1, Domain: "second.example.com"}
|
||||
if err := db.DB(ctx).Create(&zoneDomain).Error; err != nil {
|
||||
t.Fatalf("Create(zoneDomain) error = %v", err)
|
||||
}
|
||||
secondGroup := model.CFPointingGroup{Name: "secondary", PrimaryNodeID: group.PrimaryNodeID, BackupNodeID: &backup.ID, ActiveNodeID: group.PrimaryNodeID, Enabled: true}
|
||||
if err := db.DB(ctx).Create(&secondGroup).Error; err != nil {
|
||||
t.Fatalf("Create(secondGroup) error = %v", err)
|
||||
}
|
||||
secondMember := model.CFPointingMember{GroupID: secondGroup.ID, ZoneDomainID: zoneDomain.ID, SyncStatus: model.CFMemberSyncPending}
|
||||
if err := db.DB(ctx).Create(&secondMember).Error; err != nil {
|
||||
t.Fatalf("Create(secondMember) error = %v", err)
|
||||
}
|
||||
|
||||
dispatchErr := errors.New("dispatch unavailable")
|
||||
dispatchCalls := 0
|
||||
restoreDispatch := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) {
|
||||
dispatchCalls++
|
||||
if dispatchCalls == 1 {
|
||||
return "", dispatchErr
|
||||
}
|
||||
return "task-id", nil
|
||||
})
|
||||
t.Cleanup(restoreDispatch)
|
||||
|
||||
changed, err := CheckNodeFailover(ctx)
|
||||
if !errors.Is(err, dispatchErr) {
|
||||
t.Fatalf("CheckNodeFailover() error = %v, want dispatch error", err)
|
||||
}
|
||||
if changed != 2 {
|
||||
t.Errorf("CheckNodeFailover() changed = %d, want 2", changed)
|
||||
}
|
||||
if dispatchCalls != 2 {
|
||||
t.Errorf("CheckNodeFailover() dispatch calls = %d, want 2", dispatchCalls)
|
||||
}
|
||||
for _, groupID := range []uint{group.ID, secondGroup.ID} {
|
||||
updated, err := repository.GetCFPointingGroup(ctx, groupID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingGroup(%d) error = %v", groupID, err)
|
||||
}
|
||||
if updated.ActiveNodeID != backup.ID {
|
||||
t.Errorf("group %d active node = %d, want %d", groupID, updated.ActiveNodeID, backup.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchEnableProxyContinuesAfterDispatchError(t *testing.T) {
|
||||
ctx, firstMemberID := setupCloudflareLogicDB(t)
|
||||
firstMember, err := repository.GetCFPointingMemberByID(ctx, firstMemberID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
zoneDomain := model.ZoneDomain{ZoneID: 1, Domain: "second.example.com"}
|
||||
if err := db.DB(ctx).Create(&zoneDomain).Error; err != nil {
|
||||
t.Fatalf("Create(zoneDomain) error = %v", err)
|
||||
}
|
||||
secondMember := model.CFPointingMember{GroupID: firstMember.GroupID, ZoneDomainID: zoneDomain.ID}
|
||||
if err := db.DB(ctx).Create(&secondMember).Error; err != nil {
|
||||
t.Fatalf("Create(secondMember) error = %v", err)
|
||||
}
|
||||
|
||||
dispatchErr := errors.New("dispatch unavailable")
|
||||
dispatchCalls := 0
|
||||
restoreDispatch := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) {
|
||||
dispatchCalls++
|
||||
if dispatchCalls == 1 {
|
||||
return "", dispatchErr
|
||||
}
|
||||
return "task-id", nil
|
||||
})
|
||||
t.Cleanup(restoreDispatch)
|
||||
|
||||
err = BatchEnableProxy(ctx, firstMember.GroupID, MemberBatchProxyInput{MemberIDs: []uint{firstMember.ID, secondMember.ID}})
|
||||
if !errors.Is(err, dispatchErr) {
|
||||
t.Fatalf("BatchEnableProxy() error = %v, want dispatch error", err)
|
||||
}
|
||||
if dispatchCalls != 2 {
|
||||
t.Errorf("BatchEnableProxy() dispatch calls = %d, want 2", dispatchCalls)
|
||||
}
|
||||
for _, memberID := range []uint{firstMember.ID, secondMember.ID} {
|
||||
updated, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID(%d) error = %v", memberID, err)
|
||||
}
|
||||
if !updated.Proxied {
|
||||
t.Errorf("member %d proxied = false, want true", memberID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchEnableProxyContinuesAfterMemberLookupError(t *testing.T) {
|
||||
ctx, memberID := setupCloudflareLogicDB(t)
|
||||
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
dispatchCalls := 0
|
||||
restoreDispatch := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) {
|
||||
dispatchCalls++
|
||||
return "task-id", nil
|
||||
})
|
||||
t.Cleanup(restoreDispatch)
|
||||
|
||||
err = BatchEnableProxy(ctx, member.GroupID, MemberBatchProxyInput{MemberIDs: []uint{99999, member.ID}})
|
||||
if err == nil {
|
||||
t.Fatal("BatchEnableProxy() error = nil, want member lookup error")
|
||||
}
|
||||
if dispatchCalls != 1 {
|
||||
t.Errorf("BatchEnableProxy() dispatch calls = %d, want 1", dispatchCalls)
|
||||
}
|
||||
updated, err := repository.GetCFPointingMemberByID(ctx, member.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
if !updated.Proxied {
|
||||
t.Errorf("member %d proxied = false, want true", member.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveMemberAndBatchOperations(t *testing.T) {
|
||||
ctx, member1ID := setupCloudflareLogicDB(t)
|
||||
|
||||
var dispatchedTasks []string
|
||||
restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
|
||||
dispatchedTasks = append(dispatchedTasks, triggeredBy)
|
||||
return "mock-task-id", nil
|
||||
})
|
||||
t.Cleanup(restoreDispatch)
|
||||
|
||||
fake := &fakeClient{}
|
||||
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
|
||||
t.Cleanup(restoreClient)
|
||||
|
||||
member1, err := repository.GetCFPointingMemberByID(ctx, member1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
sourceGroupID := member1.GroupID
|
||||
|
||||
// Create target group
|
||||
targetGroup := model.CFPointingGroup{
|
||||
Name: "secondary",
|
||||
PrimaryNodeID: 1,
|
||||
ActiveNodeID: 1,
|
||||
DefaultProxied: true,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.DB(ctx).Create(&targetGroup).Error; err != nil {
|
||||
t.Fatalf("Create(targetGroup) error = %v", err)
|
||||
}
|
||||
|
||||
// 1. Target group equals source group -> errTargetGroupSame
|
||||
t.Run("MoveMember target equals source", func(t *testing.T) {
|
||||
_, err := MoveMember(ctx, sourceGroupID, member1.ID, sourceGroupID)
|
||||
if err == nil || err.Error() != errTargetGroupSame {
|
||||
t.Fatalf("MoveMember() error = %v, want %s", err, errTargetGroupSame)
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Target group does not exist -> errTargetGroupInvalid
|
||||
t.Run("MoveMember target group invalid", func(t *testing.T) {
|
||||
_, err := MoveMember(ctx, sourceGroupID, member1.ID, 99999)
|
||||
if err == nil || err.Error() != errTargetGroupInvalid {
|
||||
t.Fatalf("MoveMember() error = %v, want %s", err, errTargetGroupInvalid)
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Successfully move member to target group
|
||||
t.Run("MoveMember success", func(t *testing.T) {
|
||||
item, err := MoveMember(ctx, sourceGroupID, member1.ID, targetGroup.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveMember() error = %v", err)
|
||||
}
|
||||
if item == nil || item.GroupID != targetGroup.ID {
|
||||
t.Fatalf("MoveMember() returned item group ID = %v, want %d", item, targetGroup.ID)
|
||||
}
|
||||
|
||||
updated, err := repository.GetCFPointingMemberByID(ctx, member1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
if updated.GroupID != targetGroup.ID {
|
||||
t.Errorf("member GroupID = %d, want %d", updated.GroupID, targetGroup.ID)
|
||||
}
|
||||
if updated.SyncStatus != model.CFMemberSyncPending {
|
||||
t.Errorf("member SyncStatus = %s, want %s", updated.SyncStatus, model.CFMemberSyncPending)
|
||||
}
|
||||
if updated.LastError != "" {
|
||||
t.Errorf("member LastError = %q, want empty", updated.LastError)
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Batch move multiple members
|
||||
t.Run("BatchMoveMembers", func(t *testing.T) {
|
||||
// Test empty members error
|
||||
emptyErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: nil, TargetGroupID: targetGroup.ID})
|
||||
if emptyErr == nil || emptyErr.Error() != errNoMembersSelected {
|
||||
t.Fatalf("BatchMoveMembers() empty error = %v, want %s", emptyErr, errNoMembersSelected)
|
||||
}
|
||||
|
||||
// Test target equals source error
|
||||
sameErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: []uint{1}, TargetGroupID: sourceGroupID})
|
||||
if sameErr == nil || sameErr.Error() != errTargetGroupSame {
|
||||
t.Fatalf("BatchMoveMembers() same group error = %v, want %s", sameErr, errTargetGroupSame)
|
||||
}
|
||||
|
||||
// Test invalid target group error
|
||||
invalidErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: []uint{1}, TargetGroupID: 99999})
|
||||
if invalidErr == nil || invalidErr.Error() != errTargetGroupInvalid {
|
||||
t.Fatalf("BatchMoveMembers() invalid group error = %v, want %s", invalidErr, errTargetGroupInvalid)
|
||||
}
|
||||
|
||||
// Create 2 additional members in sourceGroup
|
||||
domain2 := model.ZoneDomain{ZoneID: 1, Domain: "test2.example.com"}
|
||||
domain3 := model.ZoneDomain{ZoneID: 1, Domain: "test3.example.com"}
|
||||
if err := db.DB(ctx).Create(&domain2).Error; err != nil {
|
||||
t.Fatalf("Create(domain2) error = %v", err)
|
||||
}
|
||||
if err := db.DB(ctx).Create(&domain3).Error; err != nil {
|
||||
t.Fatalf("Create(domain3) error = %v", err)
|
||||
}
|
||||
member2 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
|
||||
member3 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain3.ID, Proxied: true, SyncStatus: model.CFMemberSyncOK}
|
||||
if err := db.DB(ctx).Create(&member2).Error; err != nil {
|
||||
t.Fatalf("Create(member2) error = %v", err)
|
||||
}
|
||||
if err := db.DB(ctx).Create(&member3).Error; err != nil {
|
||||
t.Fatalf("Create(member3) error = %v", err)
|
||||
}
|
||||
|
||||
// Perform batch move
|
||||
if err := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{
|
||||
MemberIDs: []uint{member2.ID, member3.ID},
|
||||
TargetGroupID: targetGroup.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("BatchMoveMembers() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify updated in DB
|
||||
for _, mid := range []uint{member2.ID, member3.ID} {
|
||||
m, err := repository.GetCFPointingMemberByID(ctx, mid)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID(%d) error = %v", mid, err)
|
||||
}
|
||||
if m.GroupID != targetGroup.ID {
|
||||
t.Errorf("member %d GroupID = %d, want %d", mid, m.GroupID, targetGroup.ID)
|
||||
}
|
||||
if m.SyncStatus != model.CFMemberSyncPending {
|
||||
t.Errorf("member %d SyncStatus = %s, want %s", mid, m.SyncStatus, model.CFMemberSyncPending)
|
||||
}
|
||||
if m.LastError != "" {
|
||||
t.Errorf("member %d LastError = %q, want empty", mid, m.LastError)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 5. Batch remove members
|
||||
t.Run("BatchRemoveMembers", func(t *testing.T) {
|
||||
// Test empty members error
|
||||
emptyErr := BatchRemoveMembers(ctx, targetGroup.ID, MemberBatchRemoveInput{MemberIDs: nil})
|
||||
if emptyErr == nil || emptyErr.Error() != errNoMembersSelected {
|
||||
t.Fatalf("BatchRemoveMembers() empty error = %v, want %s", emptyErr, errNoMembersSelected)
|
||||
}
|
||||
|
||||
// Create members to remove
|
||||
domain4 := model.ZoneDomain{ZoneID: 1, Domain: "test4.example.com"}
|
||||
domain5 := model.ZoneDomain{ZoneID: 1, Domain: "test5.example.com"}
|
||||
if err := db.DB(ctx).Create(&domain4).Error; err != nil {
|
||||
t.Fatalf("Create(domain4) error = %v", err)
|
||||
}
|
||||
if err := db.DB(ctx).Create(&domain5).Error; err != nil {
|
||||
t.Fatalf("Create(domain5) error = %v", err)
|
||||
}
|
||||
member4 := model.CFPointingMember{GroupID: targetGroup.ID, ZoneDomainID: domain4.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
|
||||
member5 := model.CFPointingMember{GroupID: targetGroup.ID, ZoneDomainID: domain5.ID, Proxied: true, SyncStatus: model.CFMemberSyncOK}
|
||||
if err := db.DB(ctx).Create(&member4).Error; err != nil {
|
||||
t.Fatalf("Create(member4) error = %v", err)
|
||||
}
|
||||
if err := db.DB(ctx).Create(&member5).Error; err != nil {
|
||||
t.Fatalf("Create(member5) error = %v", err)
|
||||
}
|
||||
|
||||
// Perform batch remove
|
||||
if err := BatchRemoveMembers(ctx, targetGroup.ID, MemberBatchRemoveInput{
|
||||
MemberIDs: []uint{member4.ID, member5.ID},
|
||||
}); err != nil {
|
||||
t.Fatalf("BatchRemoveMembers() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify deleted from DB
|
||||
for _, mid := range []uint{member4.ID, member5.ID} {
|
||||
_, err := repository.GetCFPointingMemberByID(ctx, mid)
|
||||
if err == nil {
|
||||
t.Errorf("GetCFPointingMemberByID(%d) should be deleted, but found", mid)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -23,11 +23,9 @@ const (
|
||||
memberSyncStatusColumn = "sync_status"
|
||||
)
|
||||
|
||||
var errCachedRecordUnavailable = errors.New("cached Cloudflare record unavailable")
|
||||
|
||||
var memberLocks [memberLockStripeCount]sync.Mutex
|
||||
|
||||
// ReconcileMember makes one Cloudflare DNS record match the local desired state.
|
||||
// ReconcileMember makes one Cloudflare A record match the local desired state.
|
||||
func ReconcileMember(ctx context.Context, memberID uint) error {
|
||||
lock := &memberLocks[memberID%memberLockStripeCount]
|
||||
lock.Lock()
|
||||
@@ -53,9 +51,9 @@ func reconcileMember(ctx context.Context, memberID uint) error {
|
||||
if !state.Group.Enabled {
|
||||
return errors.New(errGroupDisabled)
|
||||
}
|
||||
input, err := desiredRecordInput(state)
|
||||
if err != nil {
|
||||
return err
|
||||
ip := strings.TrimSpace(state.Node.IP)
|
||||
if net.ParseIP(ip).To4() == nil {
|
||||
return errors.New(errNodeIPv4Required)
|
||||
}
|
||||
connection, err := repository.GetCFConnection(ctx)
|
||||
if err != nil || connection.Status != model.CFConnectionStatusReady {
|
||||
@@ -74,79 +72,37 @@ func reconcileMember(ctx context.Context, memberID uint) error {
|
||||
}
|
||||
zoneID = zone.ID
|
||||
}
|
||||
input.Name = state.Domain.Domain
|
||||
input.Proxied = state.Member.Proxied
|
||||
input.TTL = 300
|
||||
input := RecordInput{Type: "A", Name: state.Domain.Domain, Content: ip, Proxied: state.Member.Proxied, TTL: 300}
|
||||
if input.Proxied {
|
||||
input.TTL = 1
|
||||
}
|
||||
recordID := state.Member.CFRecordID
|
||||
if recordID != "" {
|
||||
completed, cachedErr := reconcileCachedRecord(ctx, client, memberID, zoneID, recordID, input)
|
||||
if errors.Is(cachedErr, errCachedRecordUnavailable) {
|
||||
cachedErr = nil
|
||||
}
|
||||
if cachedErr != nil {
|
||||
return cachedErr
|
||||
}
|
||||
if completed {
|
||||
return nil
|
||||
if _, getErr := client.GetRecord(ctx, zoneID, recordID); getErr == nil {
|
||||
record, updateErr := client.UpdateARecord(ctx, zoneID, recordID, input)
|
||||
if updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
return markMemberSynced(ctx, memberID, zoneID, record.ID, ip)
|
||||
}
|
||||
}
|
||||
records, err := client.ListRecords(ctx, zoneID, state.Domain.Domain, input.Type)
|
||||
records, err := client.ListARecords(ctx, zoneID, state.Domain.Domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var record *DNSRecord
|
||||
switch len(records) {
|
||||
case 0:
|
||||
record, err = client.CreateRecord(ctx, zoneID, input)
|
||||
record, err = client.CreateARecord(ctx, zoneID, input)
|
||||
case 1:
|
||||
record, err = client.UpdateRecord(ctx, zoneID, records[0].ID, input)
|
||||
record, err = client.UpdateARecord(ctx, zoneID, records[0].ID, input)
|
||||
default:
|
||||
return errors.New(errMultipleDNSRecords)
|
||||
return errors.New(errMultipleARecords)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return markMemberSynced(ctx, memberID, zoneID, record.ID, input.Content)
|
||||
}
|
||||
|
||||
func reconcileCachedRecord(
|
||||
ctx context.Context,
|
||||
client Client,
|
||||
memberID uint,
|
||||
zoneID, recordID string,
|
||||
input RecordInput,
|
||||
) (bool, error) {
|
||||
current, err := client.GetRecord(ctx, zoneID, recordID)
|
||||
if err != nil {
|
||||
return false, errors.Join(errCachedRecordUnavailable, err)
|
||||
}
|
||||
if current.Type != input.Type {
|
||||
return false, client.DeleteRecord(ctx, zoneID, recordID)
|
||||
}
|
||||
record, err := client.UpdateRecord(ctx, zoneID, recordID, input)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, markMemberSynced(ctx, memberID, zoneID, record.ID, input.Content)
|
||||
}
|
||||
|
||||
func desiredRecordInput(state *repository.CFPointingMemberContext) (RecordInput, error) {
|
||||
if state.Group.TargetMode == model.CFPointingTargetModeCustom {
|
||||
recordType := strings.ToUpper(strings.TrimSpace(state.Group.RecordType))
|
||||
content := strings.TrimSpace(state.Group.RecordContent)
|
||||
if !validCustomRecordType(recordType) || !validCustomRecordContent(recordType, content) {
|
||||
return RecordInput{}, errors.New(errRecordContentInvalid)
|
||||
}
|
||||
return RecordInput{Type: recordType, Content: content}, nil
|
||||
}
|
||||
ip := strings.TrimSpace(state.Node.IP)
|
||||
if net.ParseIP(ip).To4() == nil {
|
||||
return RecordInput{}, errors.New(errNodeIPv4Required)
|
||||
}
|
||||
return RecordInput{Type: "A", Content: ip}, nil
|
||||
return markMemberSynced(ctx, memberID, zoneID, record.ID, ip)
|
||||
}
|
||||
|
||||
func markMemberSynced(ctx context.Context, memberID uint, zoneID, recordID, ip string) error {
|
||||
@@ -157,7 +113,7 @@ func markMemberSynced(ctx context.Context, memberID uint, zoneID, recordID, ip s
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteManagedRecord deletes the cached or uniquely discoverable managed record.
|
||||
// DeleteManagedRecord deletes the cached or uniquely discoverable A record.
|
||||
func DeleteManagedRecord(ctx context.Context, memberID uint) error {
|
||||
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
|
||||
if err != nil {
|
||||
@@ -188,14 +144,7 @@ func DeleteManagedRecord(ctx context.Context, memberID uint) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
recordType := "A"
|
||||
if state.Group.TargetMode == model.CFPointingTargetModeCustom {
|
||||
recordType = state.Group.RecordType
|
||||
if recordType == "" {
|
||||
recordType = "A"
|
||||
}
|
||||
}
|
||||
records, err := client.ListRecords(ctx, zoneID, state.Domain.Domain, recordType)
|
||||
records, err := client.ListARecords(ctx, zoneID, state.Domain.Domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -203,7 +152,7 @@ func DeleteManagedRecord(ctx context.Context, memberID uint) error {
|
||||
return nil
|
||||
}
|
||||
if len(records) > 1 {
|
||||
return errors.New(errMultipleDNSRecords)
|
||||
return errors.New(errMultipleARecords)
|
||||
}
|
||||
return client.DeleteRecord(ctx, zoneID, records[0].ID)
|
||||
}
|
||||
|
||||
@@ -31,22 +31,16 @@ func (client *fakeClient) FindZone(context.Context, string) (*Zone, error) {
|
||||
func (client *fakeClient) GetRecord(context.Context, string, string) (*DNSRecord, error) {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
func (client *fakeClient) ListRecords(_ context.Context, _, _ string, recordType string) ([]DNSRecord, error) {
|
||||
items := make([]DNSRecord, 0, len(client.records))
|
||||
for _, record := range client.records {
|
||||
if record.Type == "" || record.Type == recordType {
|
||||
items = append(items, record)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
func (client *fakeClient) ListARecords(context.Context, string, string) ([]DNSRecord, error) {
|
||||
return client.records, nil
|
||||
}
|
||||
func (client *fakeClient) CreateRecord(_ context.Context, _ string, input RecordInput) (*DNSRecord, error) {
|
||||
func (client *fakeClient) CreateARecord(_ context.Context, _ string, input RecordInput) (*DNSRecord, error) {
|
||||
client.created = &input
|
||||
return &DNSRecord{ID: "record-created", Type: input.Type, Name: input.Name, Content: input.Content, Proxied: input.Proxied}, nil
|
||||
return &DNSRecord{ID: "record-created", Name: input.Name, Content: input.Content, Proxied: input.Proxied}, nil
|
||||
}
|
||||
func (client *fakeClient) UpdateRecord(_ context.Context, _, id string, input RecordInput) (*DNSRecord, error) {
|
||||
func (client *fakeClient) UpdateARecord(_ context.Context, _, id string, input RecordInput) (*DNSRecord, error) {
|
||||
client.updated = &input
|
||||
return &DNSRecord{ID: id, Type: input.Type, Name: input.Name, Content: input.Content, Proxied: input.Proxied}, nil
|
||||
return &DNSRecord{ID: id, Name: input.Name, Content: input.Content, Proxied: input.Proxied}, nil
|
||||
}
|
||||
func (client *fakeClient) DeleteRecord(_ context.Context, _, recordID string) error {
|
||||
client.deleted = append(client.deleted, recordID)
|
||||
|
||||
@@ -198,7 +198,7 @@ func UpdateGroupHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK(item))
|
||||
}
|
||||
|
||||
// DeleteGroupHandler deletes a pointing group and its managed remote DNS records.
|
||||
// DeleteGroupHandler deletes a pointing group and its managed remote A records.
|
||||
// @Summary 删除 Cloudflare 指向分组
|
||||
// @Tags openflare-cloudflare
|
||||
// @Produce json
|
||||
@@ -316,7 +316,7 @@ func UpdateMemberHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK(item))
|
||||
}
|
||||
|
||||
// RemoveMemberHandler removes a member and its managed remote DNS record.
|
||||
// RemoveMemberHandler removes a member and its managed remote A record.
|
||||
// @Summary 移出 Cloudflare 指向成员
|
||||
// @Tags openflare-cloudflare
|
||||
// @Produce json
|
||||
@@ -358,115 +358,6 @@ func SyncMemberHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK(&SyncReceipt{TaskID: taskID}))
|
||||
}
|
||||
|
||||
// MoveMemberHandler moves a member to a target group.
|
||||
// @Summary 移动 Cloudflare 指向成员到其他分组
|
||||
// @Tags openflare-cloudflare
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "原分组 ID"
|
||||
// @Param memberId path int true "成员 ID"
|
||||
// @Param body body cloudflare.MemberMoveInput true "目标分组参数"
|
||||
// @Success 200 {object} response.Any{data=cloudflare.MemberItem}
|
||||
// @Failure 400 {object} response.Any
|
||||
// @Failure 404 {object} response.Any
|
||||
// @Router /api/v1/d/cloudflare/groups/{id}/members/{memberId}/move [post]
|
||||
func MoveMemberHandler(c *gin.Context) {
|
||||
groupID, memberID, ok := memberParams(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input MemberMoveInput
|
||||
if !apiutil.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
item, err := MoveMember(c.Request.Context(), groupID, memberID, input.TargetGroupID)
|
||||
if abortLogic(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(item))
|
||||
}
|
||||
|
||||
// BatchMoveMembersHandler moves multiple members to a target group.
|
||||
// @Summary 批量移动 Cloudflare 指向成员
|
||||
// @Tags openflare-cloudflare
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "原分组 ID"
|
||||
// @Param body body cloudflare.MemberBatchMoveInput true "批量移动参数"
|
||||
// @Success 200 {object} response.Any
|
||||
// @Failure 400 {object} response.Any
|
||||
// @Failure 404 {object} response.Any
|
||||
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-move [post]
|
||||
func BatchMoveMembersHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input MemberBatchMoveInput
|
||||
if !apiutil.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
if abortLogic(c, BatchMoveMembers(c.Request.Context(), id, input)) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
|
||||
// BatchRemoveMembersHandler removes multiple members.
|
||||
// @Summary 批量移出 Cloudflare 指向成员
|
||||
// @Tags openflare-cloudflare
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "分组 ID"
|
||||
// @Param body body cloudflare.MemberBatchRemoveInput true "批量移出参数"
|
||||
// @Success 200 {object} response.Any
|
||||
// @Failure 400 {object} response.Any
|
||||
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-remove [post]
|
||||
func BatchRemoveMembersHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input MemberBatchRemoveInput
|
||||
if !apiutil.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
if abortLogic(c, BatchRemoveMembers(c.Request.Context(), id, input)) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
|
||||
// BatchEnableProxyHandler enables orange-cloud proxy for multiple members.
|
||||
// @Summary 批量开启 Cloudflare 橙云
|
||||
// @Tags openflare-cloudflare
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param id path int true "分组 ID"
|
||||
// @Param body body cloudflare.MemberBatchProxyInput true "批量开启橙云参数"
|
||||
// @Success 200 {object} response.Any
|
||||
// @Failure 400 {object} response.Any
|
||||
// @Failure 404 {object} response.Any
|
||||
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-proxy [post]
|
||||
func BatchEnableProxyHandler(c *gin.Context) {
|
||||
id, ok := apiutil.IDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input MemberBatchProxyInput
|
||||
if !apiutil.BindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
if abortLogic(c, BatchEnableProxy(c.Request.Context(), id, input)) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
|
||||
// ListAvailableDomainsHandler lists ZoneDomains not assigned to another group.
|
||||
// @Summary 获取可加入 Cloudflare 指向的域名
|
||||
// @Tags openflare-cloudflare
|
||||
|
||||
@@ -4,15 +4,12 @@
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -78,321 +75,3 @@ func TestGetGroupWithOrphanedMemberHealsAndSucceeds(t *testing.T) {
|
||||
t.Errorf("GetCFPointingMemberByID() should return not found after healing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGroupsHandlerWithMissingNodeStillSucceeds(t *testing.T) {
|
||||
ctx, _ := setupCloudflareLogicDB(t)
|
||||
if err := db.DB(ctx).Create(&model.CFPointingGroup{
|
||||
Name: "KR",
|
||||
PrimaryNodeID: 12,
|
||||
ActiveNodeID: 12,
|
||||
Enabled: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("Create(missing-node group) error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(response.ErrorHandlerMiddleware())
|
||||
router.GET("/groups", ListGroupsHandler)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/groups", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("ListGroupsHandler status = %d, body = %s, want %d", recorder.Code, recorder.Body.String(), http.StatusOK)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, `"name":"KR"`) {
|
||||
t.Fatalf("ListGroupsHandler body = %s, want group KR", body)
|
||||
}
|
||||
if !strings.Contains(body, `"name":"primary"`) {
|
||||
t.Fatalf("ListGroupsHandler body = %s, want intact group primary", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGroupHandlerWithMissingNodeStillSucceeds(t *testing.T) {
|
||||
ctx, _ := setupCloudflareLogicDB(t)
|
||||
group := model.CFPointingGroup{Name: "KR", PrimaryNodeID: 12, ActiveNodeID: 12, Enabled: true}
|
||||
if err := db.DB(ctx).Create(&group).Error; err != nil {
|
||||
t.Fatalf("Create(missing-node group) error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(response.ErrorHandlerMiddleware())
|
||||
router.GET("/groups/:id", GetGroupHandler)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/groups/%d", group.ID), nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("GetGroupHandler status = %d, body = %s, want %d", recorder.Code, recorder.Body.String(), http.StatusOK)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"name":"KR"`) {
|
||||
t.Fatalf("GetGroupHandler body = %s, want group KR", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutersMoveMemberHandler(t *testing.T) {
|
||||
ctx, memberID := setupCloudflareLogicDB(t)
|
||||
|
||||
restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
|
||||
return "mock-task-id", nil
|
||||
})
|
||||
t.Cleanup(restoreDispatch)
|
||||
|
||||
fake := &fakeClient{}
|
||||
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
|
||||
t.Cleanup(restoreClient)
|
||||
|
||||
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
sourceGroupID := member.GroupID
|
||||
|
||||
targetGroup := model.CFPointingGroup{
|
||||
Name: "target-group",
|
||||
PrimaryNodeID: 1,
|
||||
ActiveNodeID: 1,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.DB(ctx).Create(&targetGroup).Error; err != nil {
|
||||
t.Fatalf("Create(targetGroup) error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(response.ErrorHandlerMiddleware())
|
||||
router.POST("/groups/:id/members/:memberId/move", MoveMemberHandler)
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", sourceGroupID, memberID), strings.NewReader(fmt.Sprintf(`{"target_group_id":%d}`, targetGroup.ID)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusOK)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), fmt.Sprintf(`"group_id":%d`, targetGroup.ID)) {
|
||||
t.Fatalf("MoveMemberHandler body = %s, want group_id %d", rec.Body.String(), targetGroup.ID)
|
||||
}
|
||||
|
||||
updated, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
if updated.GroupID != targetGroup.ID {
|
||||
t.Errorf("updated member GroupID = %d, want %d", updated.GroupID, targetGroup.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidTargetGroupSameAsSource", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", targetGroup.ID, memberID), strings.NewReader(fmt.Sprintf(`{"target_group_id":%d}`, targetGroup.ID)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidTargetGroupNonExistent", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", targetGroup.ID, memberID), strings.NewReader(`{"target_group_id":99999}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidParams", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/groups/abc/members/1/move", strings.NewReader(`{"target_group_id":1}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidBody", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", targetGroup.ID, memberID), strings.NewReader("invalid json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoutersBatchMoveMembersHandler(t *testing.T) {
|
||||
ctx, member1ID := setupCloudflareLogicDB(t)
|
||||
|
||||
restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
|
||||
return "mock-task-id", nil
|
||||
})
|
||||
t.Cleanup(restoreDispatch)
|
||||
|
||||
fake := &fakeClient{}
|
||||
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
|
||||
t.Cleanup(restoreClient)
|
||||
|
||||
member1, err := repository.GetCFPointingMemberByID(ctx, member1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
sourceGroupID := member1.GroupID
|
||||
|
||||
targetGroup := model.CFPointingGroup{
|
||||
Name: "batch-move-target",
|
||||
PrimaryNodeID: 1,
|
||||
ActiveNodeID: 1,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.DB(ctx).Create(&targetGroup).Error; err != nil {
|
||||
t.Fatalf("Create(targetGroup) error = %v", err)
|
||||
}
|
||||
|
||||
domain2 := model.ZoneDomain{ZoneID: 1, Domain: "bm2.example.com"}
|
||||
if err := db.DB(ctx).Create(&domain2).Error; err != nil {
|
||||
t.Fatalf("Create(domain2) error = %v", err)
|
||||
}
|
||||
member2 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
|
||||
if err := db.DB(ctx).Create(&member2).Error; err != nil {
|
||||
t.Fatalf("Create(member2) error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(response.ErrorHandlerMiddleware())
|
||||
router.POST("/groups/:id/members/batch-move", BatchMoveMembersHandler)
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-move", sourceGroupID), strings.NewReader(fmt.Sprintf(`{"member_ids":[%d,%d],"target_group_id":%d}`, member1.ID, member2.ID, targetGroup.ID)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusOK)
|
||||
}
|
||||
|
||||
for _, mid := range []uint{member1.ID, member2.ID} {
|
||||
m, err := repository.GetCFPointingMemberByID(ctx, mid)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID(%d) error = %v", mid, err)
|
||||
}
|
||||
if m.GroupID != targetGroup.ID {
|
||||
t.Errorf("member %d GroupID = %d, want %d", mid, m.GroupID, targetGroup.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmptyMemberIDs", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-move", sourceGroupID), strings.NewReader(fmt.Sprintf(`{"member_ids":[],"target_group_id":%d}`, targetGroup.ID)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TargetGroupSame", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-move", targetGroup.ID), strings.NewReader(fmt.Sprintf(`{"member_ids":[%d],"target_group_id":%d}`, member1.ID, targetGroup.ID)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidParams", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/groups/xyz/members/batch-move", strings.NewReader(`{"member_ids":[1],"target_group_id":2}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoutersBatchRemoveMembersHandler(t *testing.T) {
|
||||
ctx, member1ID := setupCloudflareLogicDB(t)
|
||||
|
||||
fake := &fakeClient{}
|
||||
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
|
||||
t.Cleanup(restoreClient)
|
||||
|
||||
member1, err := repository.GetCFPointingMemberByID(ctx, member1ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||
}
|
||||
groupID := member1.GroupID
|
||||
|
||||
domain2 := model.ZoneDomain{ZoneID: 1, Domain: "br2.example.com"}
|
||||
if err := db.DB(ctx).Create(&domain2).Error; err != nil {
|
||||
t.Fatalf("Create(domain2) error = %v", err)
|
||||
}
|
||||
member2 := model.CFPointingMember{GroupID: groupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
|
||||
if err := db.DB(ctx).Create(&member2).Error; err != nil {
|
||||
t.Fatalf("Create(member2) error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(response.ErrorHandlerMiddleware())
|
||||
router.POST("/groups/:id/members/batch-remove", BatchRemoveMembersHandler)
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-remove", groupID), strings.NewReader(fmt.Sprintf(`{"member_ids":[%d,%d]}`, member1.ID, member2.ID)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("BatchRemoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusOK)
|
||||
}
|
||||
|
||||
for _, mid := range []uint{member1.ID, member2.ID} {
|
||||
_, err := repository.GetCFPointingMemberByID(ctx, mid)
|
||||
if err == nil {
|
||||
t.Errorf("member %d should have been deleted, but still found in DB", mid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmptyMemberIDs", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-remove", groupID), strings.NewReader(`{"member_ids":[]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("BatchRemoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidParams", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/groups/invalid/members/batch-remove", strings.NewReader(`{"member_ids":[1]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("BatchRemoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@ const (
|
||||
SyncGroupTask = "cloudflare:sync_group"
|
||||
// SyncByNodeTask is the Asynq task type for members targeting one node.
|
||||
SyncByNodeTask = "cloudflare:sync_by_node"
|
||||
// FailoverCheckTask checks node availability and updates active Cloudflare targets.
|
||||
FailoverCheckTask = "cloudflare:failover_check"
|
||||
|
||||
// TaskTypeSyncMember is the task metadata type for member synchronization.
|
||||
TaskTypeSyncMember = "of_cloudflare_sync_member"
|
||||
@@ -34,29 +32,14 @@ const (
|
||||
TaskTypeSyncGroup = "of_cloudflare_sync_group"
|
||||
// TaskTypeSyncByNode is the task metadata type for node-triggered synchronization.
|
||||
TaskTypeSyncByNode = "of_cloudflare_sync_by_node"
|
||||
// TaskTypeFailoverCheck is the schedule metadata type for node failover checks.
|
||||
TaskTypeFailoverCheck = "of_cloudflare_failover_check"
|
||||
)
|
||||
|
||||
// FailoverCheckMeta describes the scheduled node failover reconciliation.
|
||||
var FailoverCheckMeta = task.TaskMeta{
|
||||
Type: TaskTypeFailoverCheck,
|
||||
AsynqTask: FailoverCheckTask,
|
||||
Name: "Cloudflare 节点故障回退检查",
|
||||
Description: "根据节点在线和应用状态切换分组主备节点",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 2,
|
||||
Queue: task.QueueDefault,
|
||||
Retryable: true,
|
||||
InternalOnly: true,
|
||||
}
|
||||
|
||||
// SyncMemberMeta describes one-member reconciliation (admin-dispatchable).
|
||||
var SyncMemberMeta = task.TaskMeta{
|
||||
Type: TaskTypeSyncMember,
|
||||
AsynqTask: SyncMemberTask,
|
||||
Name: "Cloudflare 域名同步",
|
||||
Description: "同步单个域名的 Cloudflare DNS 记录",
|
||||
Description: "同步单个域名的 Cloudflare A 记录",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 3,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -186,17 +169,14 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt
|
||||
if loadErr != nil {
|
||||
task.AppendLog(ctx, "加载成员上下文失败: member_id=%d error=%v", input.MemberID, loadErr)
|
||||
} else {
|
||||
target := fmt.Sprintf("%s(%s)", state.Node.Name, strings.TrimSpace(state.Node.IP))
|
||||
if state.Group.TargetMode == model.CFPointingTargetModeCustom {
|
||||
target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent)
|
||||
}
|
||||
task.AppendLog(ctx,
|
||||
"开始域名同步: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d",
|
||||
"开始域名同步: domain=%s zone=%s group=%s(#%d) node=%s(%s) proxied=%v member_id=%d",
|
||||
state.Domain.Domain,
|
||||
state.Zone.Domain,
|
||||
state.Group.Name,
|
||||
state.Group.ID,
|
||||
target,
|
||||
state.Node.Name,
|
||||
strings.TrimSpace(state.Node.IP),
|
||||
state.Member.Proxied,
|
||||
input.MemberID,
|
||||
)
|
||||
@@ -214,15 +194,12 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt
|
||||
|
||||
message := "Cloudflare 域名同步成功"
|
||||
if state != nil {
|
||||
target := strings.TrimSpace(state.Node.IP)
|
||||
if state.Group.TargetMode == model.CFPointingTargetModeCustom {
|
||||
target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent)
|
||||
}
|
||||
ip := strings.TrimSpace(state.Node.IP)
|
||||
message = fmt.Sprintf("Cloudflare 域名同步成功: %s → %s (proxied=%v)",
|
||||
state.Domain.Domain, target, state.Member.Proxied)
|
||||
state.Domain.Domain, ip, state.Member.Proxied)
|
||||
task.AppendLog(ctx,
|
||||
"域名同步成功: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s",
|
||||
state.Domain.Domain, target, state.Member.Proxied, state.Group.Name, state.Group.TargetMode,
|
||||
"域名同步成功: domain=%s desired_ip=%s proxied=%v group=%s node=%s",
|
||||
state.Domain.Domain, ip, state.Member.Proxied, state.Group.Name, state.Node.Name,
|
||||
)
|
||||
} else {
|
||||
task.AppendLog(ctx, "域名同步成功: member_id=%d", input.MemberID)
|
||||
@@ -265,9 +242,14 @@ func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte
|
||||
task.AppendLog(ctx, "加载分组失败: group_id=%d error=%v", input.GroupID, groupErr)
|
||||
} else {
|
||||
scopeName = group.Name
|
||||
activeNode = groupSyncTarget(ctx, group)
|
||||
if node, nodeErr := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID); nodeErr != nil {
|
||||
task.AppendLog(ctx, "加载生效节点失败: group=%s active_node_id=%d error=%v",
|
||||
group.Name, group.ActiveNodeID, nodeErr)
|
||||
} else {
|
||||
activeNode = fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP))
|
||||
}
|
||||
task.AppendLog(ctx,
|
||||
"准备分组同步: group=%s id=%d enabled=%v target=%s default_proxied=%v",
|
||||
"准备分组同步: group=%s id=%d enabled=%v active_node=%s default_proxied=%v",
|
||||
group.Name, group.ID, group.Enabled, activeNode, group.DefaultProxied,
|
||||
)
|
||||
}
|
||||
@@ -276,19 +258,6 @@ func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte
|
||||
return executeBatchSync(ctx, members, err, "分组", scopeName, input.GroupID, activeNode)
|
||||
}
|
||||
|
||||
func groupSyncTarget(ctx context.Context, group *model.CFPointingGroup) string {
|
||||
if group.TargetMode == model.CFPointingTargetModeCustom {
|
||||
return fmt.Sprintf("%s %s", group.RecordType, group.RecordContent)
|
||||
}
|
||||
node, err := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID)
|
||||
if err != nil {
|
||||
task.AppendLog(ctx, "加载生效节点失败: group=%s active_node_id=%d error=%v",
|
||||
group.Name, group.ActiveNodeID, err)
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP))
|
||||
}
|
||||
|
||||
// SyncByNodeTaskHandler reconciles every member targeting a node.
|
||||
type SyncByNodeTaskHandler struct{}
|
||||
|
||||
@@ -341,7 +310,7 @@ func executeBatchSync(
|
||||
return nil, listErr
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "开始%s同步: name=%s id=%d target=%s 域名数=%d",
|
||||
task.AppendLog(ctx, "开始%s同步: name=%s id=%d active_node=%s 域名数=%d",
|
||||
scope, scopeName, scopeID, activeNode, len(members))
|
||||
if len(members) == 0 {
|
||||
message := fmt.Sprintf("Cloudflare %s同步完成: %s 无域名成员", scope, scopeName)
|
||||
@@ -395,31 +364,3 @@ func decodePayload(payload []byte, target any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FailoverCheckTaskHandler checks enabled node groups for failover and recovery.
|
||||
type FailoverCheckTaskHandler struct{}
|
||||
|
||||
// ValidatePayload validates the empty scheduled-task payload.
|
||||
func (handler *FailoverCheckTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
var input struct{}
|
||||
if err := decodePayload(payload, &input); err != nil {
|
||||
return nil, fmt.Errorf("无效的 Cloudflare 故障回退检查参数: %w", err)
|
||||
}
|
||||
return json.Marshal(input)
|
||||
}
|
||||
|
||||
// Execute switches affected groups and queues DNS reconciliation.
|
||||
func (handler *FailoverCheckTaskHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
if _, err := handler.ValidatePayload(payload); err != nil {
|
||||
return nil, task.PermanentError(err.Error())
|
||||
}
|
||||
task.AppendLog(ctx, "开始检查 Cloudflare 节点分组主备状态")
|
||||
changed, err := CheckNodeFailover(ctx)
|
||||
if err != nil {
|
||||
task.AppendLog(ctx, "Cloudflare 节点故障回退检查失败: error=%v", err)
|
||||
return nil, err
|
||||
}
|
||||
message := fmt.Sprintf("Cloudflare 节点故障回退检查完成,切换分组 %d 个", changed)
|
||||
task.AppendLog(ctx, "%s", message)
|
||||
return &task.TaskResult{Message: message}, nil
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ type NodeOption struct {
|
||||
// GroupInput creates or updates a pointing group.
|
||||
type GroupInput struct {
|
||||
Name string `json:"name"`
|
||||
TargetMode string `json:"target_mode"`
|
||||
RecordType string `json:"record_type"`
|
||||
RecordContent string `json:"record_content"`
|
||||
PrimaryNodeID uint `json:"primary_node_id"`
|
||||
BackupNodeID *uint `json:"backup_node_id"`
|
||||
DefaultProxied bool `json:"default_proxied"`
|
||||
@@ -45,9 +42,6 @@ type GroupInput struct {
|
||||
type GroupItem struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TargetMode string `json:"target_mode"`
|
||||
RecordType string `json:"record_type"`
|
||||
RecordContent string `json:"record_content"`
|
||||
PrimaryNode NodeOption `json:"primary_node"`
|
||||
BackupNode *NodeOption `json:"backup_node"`
|
||||
ActiveNode NodeOption `json:"active_node"`
|
||||
@@ -69,27 +63,6 @@ type MemberUpdateInput struct {
|
||||
Proxied bool `json:"proxied"`
|
||||
}
|
||||
|
||||
// MemberMoveInput contains the target group ID for moving a member.
|
||||
type MemberMoveInput struct {
|
||||
TargetGroupID uint `json:"target_group_id"`
|
||||
}
|
||||
|
||||
// MemberBatchMoveInput contains the member IDs and target group ID for batch moving.
|
||||
type MemberBatchMoveInput struct {
|
||||
MemberIDs []uint `json:"member_ids"`
|
||||
TargetGroupID uint `json:"target_group_id"`
|
||||
}
|
||||
|
||||
// MemberBatchRemoveInput contains the member IDs for batch deletion.
|
||||
type MemberBatchRemoveInput struct {
|
||||
MemberIDs []uint `json:"member_ids"`
|
||||
}
|
||||
|
||||
// MemberBatchProxyInput contains the members to enable orange-cloud proxy for.
|
||||
type MemberBatchProxyInput struct {
|
||||
MemberIDs []uint `json:"member_ids"`
|
||||
}
|
||||
|
||||
// MemberItem is the admin-facing member state.
|
||||
type MemberItem struct {
|
||||
ID uint `json:"id"`
|
||||
|
||||
@@ -444,7 +444,6 @@ func snapshotPagesDeploymentEqual(left, right *openrestyrender.PagesDeployment)
|
||||
|
||||
func snapshotRoutePolicyEqual(left, right snapshotRoute) bool {
|
||||
return left.EnableHTTPS == right.EnableHTTPS &&
|
||||
left.EnableHTTP2 == right.EnableHTTP2 &&
|
||||
left.RedirectHTTP == right.RedirectHTTP &&
|
||||
left.LimitConnPerServer == right.LimitConnPerServer &&
|
||||
left.LimitConnPerIP == right.LimitConnPerIP &&
|
||||
@@ -531,7 +530,6 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
|
||||
appendIfChanged("OpenRestyCacheLockEnabled", strconv.FormatBool(left.CacheLockEnabled), strconv.FormatBool(right.CacheLockEnabled))
|
||||
appendIfChanged("OpenRestyCacheLockTimeout", left.CacheLockTimeout, right.CacheLockTimeout)
|
||||
appendIfChanged("OpenRestyCacheUseStale", left.CacheUseStale, right.CacheUseStale)
|
||||
appendIfChanged("OpenRestyTrustedProxyCIDRs", strings.Join(effectiveTrustedProxyCIDRs(left.TrustedProxyCIDRs), ","), strings.Join(effectiveTrustedProxyCIDRs(right.TrustedProxyCIDRs), ","))
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerServer", strconv.Itoa(left.DefaultLimitConnPerServer), strconv.Itoa(right.DefaultLimitConnPerServer))
|
||||
appendIfChanged("OpenRestyDefaultLimitConnPerIP", strconv.Itoa(left.DefaultLimitConnPerIP), strconv.Itoa(right.DefaultLimitConnPerIP))
|
||||
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
|
||||
@@ -613,7 +611,6 @@ func openRestyOptionKeys() []string {
|
||||
"OpenRestyCacheLockEnabled",
|
||||
"OpenRestyCacheLockTimeout",
|
||||
"OpenRestyCacheUseStale",
|
||||
"OpenRestyTrustedProxyCIDRs",
|
||||
"OpenRestyDefaultLimitConnPerServer",
|
||||
"OpenRestyDefaultLimitConnPerIP",
|
||||
"OpenRestyDefaultLimitRate",
|
||||
@@ -627,10 +624,3 @@ func openRestyOptionKeys() []string {
|
||||
"SWOfflineDomains",
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveTrustedProxyCIDRs(cidrs []string) []string {
|
||||
if cidrs == nil {
|
||||
return openrestyrender.DefaultTrustedProxyCIDRs()
|
||||
}
|
||||
return cidrs
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/waf"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/Rain-kl/Wavelet/pkg/protocol"
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
"gorm.io/gorm"
|
||||
@@ -51,7 +50,6 @@ type snapshotRoute struct {
|
||||
Upstreams []string `json:"upstreams,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnableHTTPS bool `json:"enable_https"`
|
||||
EnableHTTP2 bool `json:"enable_http2"`
|
||||
DomainCertIDs []uint `json:"domain_cert_ids,omitempty"`
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
|
||||
@@ -102,7 +100,6 @@ type snapshotWAFDocument struct {
|
||||
}
|
||||
|
||||
type openRestyConfigSnapshot struct {
|
||||
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs"`
|
||||
DefaultServerReturnStatus int `json:"default_server_return_status"`
|
||||
WorkerProcesses string `json:"worker_processes"`
|
||||
WorkerConnections int `json:"worker_connections"`
|
||||
@@ -291,7 +288,6 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
|
||||
Upstreams: upstreams,
|
||||
Enabled: route.Enabled,
|
||||
EnableHTTPS: route.EnableHTTPS,
|
||||
EnableHTTP2: route.EnableHTTP2,
|
||||
DomainCertIDs: domainCertIDs,
|
||||
RedirectHTTP: route.RedirectHTTP,
|
||||
LimitConnPerServer: route.LimitConnPerServer,
|
||||
@@ -551,7 +547,7 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
||||
ProxyReadTimeout: getIntConfig(model.ConfigKeyOpenRestyProxyReadTimeout, defaultOpenRestyProxyTimeout),
|
||||
WebsocketEnabled: getBoolConfig(model.ConfigKeyOpenRestyWebsocketEnabled, true),
|
||||
HTTP3Enabled: getBoolConfig(model.ConfigKeyOpenRestyHTTP3Enabled, true),
|
||||
ProxyRequestBuffering: getBoolConfig(model.ConfigKeyOpenRestyProxyRequestBufferingEnabled, true),
|
||||
ProxyRequestBuffering: getBoolConfig(model.ConfigKeyOpenRestyProxyRequestBufferingEnabled, false),
|
||||
ProxyBufferingEnabled: getBoolConfig(model.ConfigKeyOpenRestyProxyBufferingEnabled, true),
|
||||
ProxyBuffers: getStringConfig(model.ConfigKeyOpenRestyProxyBuffers, "16 16k"),
|
||||
ProxyBufferSize: getStringConfig(model.ConfigKeyOpenRestyProxyBufferSize, "8k"),
|
||||
@@ -570,7 +566,6 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
||||
CacheLockTimeout: getStringConfig(model.ConfigKeyOpenRestyCacheLockTimeout, "5s"),
|
||||
CacheUseStale: getStringConfig(model.ConfigKeyOpenRestyCacheUseStale, "error timeout updating http_500 http_502 http_503 http_504"),
|
||||
MainConfigTemplate: getStringConfig(model.ConfigKeyOpenRestyMainConfigTemplate, model.DefaultOpenRestyMainConfigTemplate),
|
||||
TrustedProxyCIDRs: getTrustedProxyCIDRsConfig(ctx),
|
||||
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
|
||||
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
|
||||
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
|
||||
@@ -593,26 +588,6 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func parseTrustedProxyCIDRs(value string) []string {
|
||||
var cidrs []string
|
||||
if err := json.Unmarshal([]byte(value), &cidrs); err != nil || cidrs == nil {
|
||||
return []string{}
|
||||
}
|
||||
return cidrs
|
||||
}
|
||||
|
||||
func getTrustedProxyCIDRsConfig(ctx context.Context) []string {
|
||||
config, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyOpenRestyTrustedProxyCIDRs)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return openrestyrender.DefaultTrustedProxyCIDRs()
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[OpenFlareConfig] read trusted proxy CIDRs failed: error=%v", err)
|
||||
return []string{}
|
||||
}
|
||||
return parseTrustedProxyCIDRs(config.Value)
|
||||
}
|
||||
|
||||
func parseOriginErrorPageStatusCodes(raw string) []string {
|
||||
const defaultTag = "500-599"
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config_version
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
)
|
||||
|
||||
func TestTrustedProxyCIDRsSurviveSnapshotRendering(t *testing.T) {
|
||||
snapshot := snapshotDocument{
|
||||
OpenRestyConfig: openRestyConfigSnapshot{
|
||||
MainConfigTemplate: model.DefaultOpenRestyMainConfigTemplate,
|
||||
TrustedProxyCIDRs: parseTrustedProxyCIDRs(`["173.245.48.0/20","2001:db8:1234::/48"]`),
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rendered, err := renderSnapshotConfig(string(data), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"real_ip_header CF-Connecting-IP;", "set_real_ip_from 173.245.48.0/20;", "set_real_ip_from 2001:db8:1234::/48;"} {
|
||||
if !strings.Contains(rendered.MainConfig, expected) {
|
||||
t.Errorf("rendered snapshot missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxyCIDRsSnapshotEmptyAndLegacyDefaults(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
json string
|
||||
want int
|
||||
}{
|
||||
{name: "explicit empty list remains disabled", json: `{"openresty_config":{"trusted_proxy_cidrs":[]}}`, want: 0},
|
||||
{name: "legacy snapshot defaults to Cloudflare ranges", json: `{"openresty_config":{}}`, want: 22},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rendered, err := renderSnapshotConfig(testCase.json, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Count(rendered.MainConfig, "set_real_ip_from "); got != testCase.want {
|
||||
t.Fatalf("rendered trusted proxy range count = %d, want %d", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxyCIDRConfigMissingAndExplicitEmpty(t *testing.T) {
|
||||
cleanup := setupOriginErrorPageSnapshotDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
missing := buildOpenRestyConfigSnapshot(ctx).TrustedProxyCIDRs
|
||||
if len(missing) != 22 || !slices.Equal(missing, openrestyrender.DefaultTrustedProxyCIDRs()) {
|
||||
t.Fatalf("missing database key defaults to %#v, want the 22 Cloudflare ranges", missing)
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Create(&model.SystemConfig{Key: model.ConfigKeyOpenRestyTrustedProxyCIDRs, Value: `[]`, Type: "business"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitEmpty := buildOpenRestyConfigSnapshot(ctx).TrustedProxyCIDRs
|
||||
if explicitEmpty == nil || len(explicitEmpty) != 0 {
|
||||
t.Fatalf("explicit [] must remain a non-nil empty list, got %#v", explicitEmpty)
|
||||
}
|
||||
payload, err := json.Marshal(snapshotDocument{OpenRestyConfig: buildOpenRestyConfigSnapshot(ctx)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(payload), `"trusted_proxy_cidrs":[]`) {
|
||||
t.Fatalf("explicit empty list was omitted from snapshot: %s", payload)
|
||||
}
|
||||
rendered, err := renderSnapshotConfig(string(payload), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(rendered.MainConfig, "set_real_ip_from ") {
|
||||
t.Fatal("explicit empty list still trusts proxy ranges")
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOpenRestyTrustedProxyCIDRs).Update("value", "invalid").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ram.ResetForTest()
|
||||
malformed := buildOpenRestyConfigSnapshot(ctx).TrustedProxyCIDRs
|
||||
if malformed == nil || len(malformed) != 0 {
|
||||
t.Fatalf("malformed configuration must fail closed, got %#v", malformed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedProxyCIDRDiffTreatsLegacyNilAsCloudflareDefaults(t *testing.T) {
|
||||
diffs := diffOpenRestyOptionDetails(openRestyConfigSnapshot{}, openRestyConfigSnapshot{TrustedProxyCIDRs: []string{}})
|
||||
for _, diff := range diffs {
|
||||
if diff.Key == "OpenRestyTrustedProxyCIDRs" {
|
||||
if diff.PreviousValue == diff.CurrentValue {
|
||||
t.Fatal("legacy nil and explicit empty list should have different effective values")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("trusted proxy CIDR change was not included in config diff")
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package option
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
const (
|
||||
maxOriginErrorPageHTMLBytes = 256 << 10 // 256 KiB
|
||||
maxSWOfflineDomains = 1000
|
||||
maxTrustedProxyCIDRs = 256
|
||||
)
|
||||
|
||||
var openRestyOptionValidators = map[string]func(key, value string) error{
|
||||
@@ -59,7 +57,6 @@ var openRestyOptionValidators = map[string]func(key, value string) error{
|
||||
model.ConfigKeyOpenRestyCacheKeyTemplate: validateOpenRestyCacheKeyTemplate,
|
||||
model.ConfigKeyOpenRestyCacheUseStale: validateOpenRestyCacheUseStale,
|
||||
model.ConfigKeyOpenRestyMainConfigTemplate: validateOpenRestyMainConfigTemplate,
|
||||
model.ConfigKeyOpenRestyTrustedProxyCIDRs: validateOpenRestyTrustedProxyCIDRs,
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
|
||||
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
|
||||
@@ -207,23 +204,6 @@ func validateOpenRestyMainConfigTemplate(key, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOpenRestyTrustedProxyCIDRs(key, value string) error {
|
||||
var cidrs []string
|
||||
if err := json.Unmarshal([]byte(value), &cidrs); err != nil || cidrs == nil {
|
||||
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
|
||||
}
|
||||
if len(cidrs) > maxTrustedProxyCIDRs {
|
||||
return fmt.Errorf("%s 最多允许 %d 个网段", key, maxTrustedProxyCIDRs)
|
||||
}
|
||||
for _, cidr := range cidrs {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||
if err != nil || prefix != prefix.Masked() {
|
||||
return fmt.Errorf("%s 包含无效网段 %q", key, cidr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
|
||||
if trimmed == "" || trimmed == "0" {
|
||||
return nil
|
||||
|
||||
@@ -70,14 +70,6 @@ func TestValidateOriginErrorPageStatusCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOpenRestyTrustedProxyCIDRs(t *testing.T) {
|
||||
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOpenRestyTrustedProxyCIDRs, `[]`))
|
||||
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOpenRestyTrustedProxyCIDRs, `["173.245.48.0/20","10.2.0.0/24"]`))
|
||||
for _, value := range []string{`null`, `10.2.0.0/24`, `["10.2.0.0/24;return 200"]`, `["]`} {
|
||||
require.Error(t, validateOpenRestyOption(model.ConfigKeyOpenRestyTrustedProxyCIDRs, value), "value %q must be rejected", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOriginErrorPageHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -113,14 +113,6 @@ func populateProxyRouteFields(
|
||||
route.UpstreamType = upstreamType
|
||||
}
|
||||
|
||||
func applyProxyRouteHTTP2(route *model.ProxyRoute, input Input) {
|
||||
if input.EnableHTTP2 != nil {
|
||||
route.EnableHTTP2 = *input.EnableHTTP2
|
||||
} else if route.ID == 0 {
|
||||
route.EnableHTTP2 = true
|
||||
}
|
||||
}
|
||||
|
||||
func applyProxyRouteUpstreamType(ctx context.Context, route *model.ProxyRoute, upstreamType string, input Input) error {
|
||||
switch upstreamType {
|
||||
case proxyRouteUpstreamTypeTunnel:
|
||||
|
||||
@@ -35,7 +35,6 @@ type Input struct {
|
||||
Upstreams []string `json:"upstreams"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnableHTTPS bool `json:"enable_https"`
|
||||
EnableHTTP2 *bool `json:"enable_http2"`
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
LimitConnPerServer int `json:"limit_conn_per_server"`
|
||||
LimitConnPerIP int `json:"limit_conn_per_ip"`
|
||||
@@ -69,7 +68,6 @@ type View struct {
|
||||
UpstreamList []string `json:"upstream_list"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnableHTTPS bool `json:"enable_https"`
|
||||
EnableHTTP2 bool `json:"enable_http2"`
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
LimitConnPerServer int `json:"limit_conn_per_server"`
|
||||
LimitConnPerIP int `json:"limit_conn_per_ip"`
|
||||
@@ -312,7 +310,6 @@ func buildProxyRoute(ctx context.Context, route *model.ProxyRoute, input Input)
|
||||
if route == nil {
|
||||
route = &model.ProxyRoute{}
|
||||
}
|
||||
applyProxyRouteHTTP2(route, input)
|
||||
populateProxyRouteFields(
|
||||
route,
|
||||
input,
|
||||
@@ -384,7 +381,6 @@ func buildProxyRouteView(ctx context.Context, route *model.ProxyRoute) (*View, e
|
||||
UpstreamList: upstreams,
|
||||
Enabled: route.Enabled,
|
||||
EnableHTTPS: route.EnableHTTPS,
|
||||
EnableHTTP2: route.EnableHTTP2,
|
||||
RedirectHTTP: route.RedirectHTTP,
|
||||
LimitConnPerServer: route.LimitConnPerServer,
|
||||
LimitConnPerIP: route.LimitConnPerIP,
|
||||
|
||||
@@ -91,32 +91,30 @@ func (b *localBackend) Test(_ context.Context) error {
|
||||
return os.MkdirAll(b.root, storageDirPerm)
|
||||
}
|
||||
|
||||
func isWithinRoot(root, target string) bool {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
absTarget, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(absRoot, absTarget)
|
||||
return err == nil && !strings.HasPrefix(rel, "..")
|
||||
}
|
||||
|
||||
func (b *localBackend) path(key string) (string, error) {
|
||||
if filepath.IsAbs(key) {
|
||||
cleanPath := filepath.Clean(key)
|
||||
if isWithinRoot(b.root, cleanPath) {
|
||||
return cleanPath, nil
|
||||
absRoot, err := filepath.Abs(b.root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absPath, err := filepath.Abs(cleanPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel, err := filepath.Rel(absRoot, absPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", errors.New("storage key escapes local root")
|
||||
}
|
||||
return cleanPath, nil
|
||||
}
|
||||
cleanKey := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(key, "/")))
|
||||
if cleanKey == "." || cleanKey == "" || strings.HasPrefix(cleanKey, "..") {
|
||||
return "", fmt.Errorf("invalid local storage key %q", key)
|
||||
}
|
||||
path := filepath.Join(b.root, cleanKey)
|
||||
if !isWithinRoot(b.root, path) {
|
||||
rel, err := filepath.Rel(b.root, path)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return "", errors.New("storage key escapes local root")
|
||||
}
|
||||
return path, nil
|
||||
|
||||
@@ -48,19 +48,4 @@ func TestLocalBackendRoundTrip(t *testing.T) {
|
||||
if _, err := backend.Get(ctx, key); err == nil {
|
||||
t.Errorf("Get(%q) after Delete() returned nil error", key)
|
||||
}
|
||||
|
||||
// Test key with leading slash
|
||||
slashKey := "/uploads/2026/06/13/slash_test.txt"
|
||||
putSlashRes, err := backend.Put(ctx, slashKey, bytes.NewBufferString(content), int64(len(content)), "text/plain")
|
||||
if err != nil {
|
||||
t.Fatalf("Put(%q) returned error: %v", slashKey, err)
|
||||
}
|
||||
if putSlashRes.Key != slashKey {
|
||||
t.Errorf("Put(%q) key = %q, want %q", slashKey, putSlashRes.Key, slashKey)
|
||||
}
|
||||
objSlash, err := backend.Get(ctx, slashKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q) returned error: %v", slashKey, err)
|
||||
}
|
||||
_ = objSlash.Body.Close()
|
||||
}
|
||||
|
||||
@@ -32,15 +32,11 @@ type webDAVBackend struct {
|
||||
}
|
||||
|
||||
func newWebDAVBackend(cfg WebDAVConfig) (*webDAVBackend, error) {
|
||||
basePath := strings.Trim(path.Clean("/"+cfg.BasePath), "/")
|
||||
if basePath == "." {
|
||||
basePath = ""
|
||||
}
|
||||
return &webDAVBackend{
|
||||
endpoint: strings.TrimRight(cfg.Endpoint, "/"),
|
||||
username: cfg.Username,
|
||||
password: cfg.Password,
|
||||
basePath: basePath,
|
||||
basePath: strings.Trim(cfg.BasePath, "/"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -54,27 +50,27 @@ func (b *webDAVBackend) newClient(ctx context.Context) *gowebdav.Client {
|
||||
}
|
||||
|
||||
func (b *webDAVBackend) Put(ctx context.Context, key string, body io.Reader, size int64, _ string) (PutResult, error) {
|
||||
target := b.targetPath(key)
|
||||
key = b.key(key)
|
||||
client := b.newClient(ctx)
|
||||
if dir := path.Dir(target); dir != "." && dir != "/" {
|
||||
if dir := path.Dir(key); dir != "." && dir != "/" {
|
||||
if err := client.MkdirAll(dir, storageDirPerm); err != nil {
|
||||
return PutResult{}, fmt.Errorf("create WebDAV directory: %w", err)
|
||||
}
|
||||
}
|
||||
if err := client.WriteStreamWithLength(target, body, size, storageFilePerm); err != nil {
|
||||
if err := client.WriteStreamWithLength(key, body, size, storageFilePerm); err != nil {
|
||||
return PutResult{}, fmt.Errorf("put WebDAV object: %w", err)
|
||||
}
|
||||
return PutResult{Key: b.relKey(key)}, nil
|
||||
return PutResult{Key: key}, nil
|
||||
}
|
||||
|
||||
func (b *webDAVBackend) Get(ctx context.Context, key string) (*Object, error) {
|
||||
target := b.targetPath(key)
|
||||
key = b.key(key)
|
||||
client := b.newClient(ctx)
|
||||
info, err := client.Stat(target)
|
||||
info, err := client.Stat(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat WebDAV object: %w", err)
|
||||
}
|
||||
body, err := client.ReadStream(target)
|
||||
body, err := client.ReadStream(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get WebDAV object: %w", err)
|
||||
}
|
||||
@@ -87,7 +83,7 @@ func (b *webDAVBackend) Get(ctx context.Context, key string) (*Object, error) {
|
||||
|
||||
func (b *webDAVBackend) Delete(ctx context.Context, key string) error {
|
||||
client := b.newClient(ctx)
|
||||
if err := client.Remove(b.targetPath(key)); err != nil {
|
||||
if err := client.Remove(b.key(key)); err != nil {
|
||||
return fmt.Errorf("delete WebDAV object: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -101,34 +97,6 @@ func (b *webDAVBackend) Test(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// relKey extracts the clean, normalized, relative logical key (e.g. "uploads/2026/09/17/xxx.jpg")
|
||||
// to be persisted in the database, stripping any driver-specific basePath and leading slashes.
|
||||
func (b *webDAVBackend) relKey(key string) string {
|
||||
cleanKey := strings.Trim(path.Clean("/"+strings.ReplaceAll(key, "\\", "/")), "/")
|
||||
if cleanKey == "." {
|
||||
return ""
|
||||
}
|
||||
if b.basePath != "" {
|
||||
for cleanKey == b.basePath || strings.HasPrefix(cleanKey, b.basePath+"/") {
|
||||
cleanKey = strings.TrimPrefix(cleanKey, b.basePath)
|
||||
cleanKey = strings.TrimPrefix(cleanKey, "/")
|
||||
}
|
||||
}
|
||||
return cleanKey
|
||||
}
|
||||
|
||||
// targetPath resolves any key (relative, legacy with basePath, or corrupted with duplicate basePath)
|
||||
// into the absolute path used to access the object on the WebDAV server.
|
||||
func (b *webDAVBackend) targetPath(key string) string {
|
||||
rel := b.relKey(key)
|
||||
if b.basePath == "" {
|
||||
if rel == "" {
|
||||
return "/"
|
||||
}
|
||||
return "/" + rel
|
||||
}
|
||||
if rel == "" {
|
||||
return "/" + b.basePath
|
||||
}
|
||||
return "/" + b.basePath + "/" + rel
|
||||
func (b *webDAVBackend) key(key string) string {
|
||||
return "/" + path.Join(b.basePath, strings.TrimLeft(key, "/"))
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package objectstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
func TestWebDAVTargetPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
basePath string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no base path, relative key",
|
||||
basePath: "",
|
||||
key: "uploads/2026/09/17/1.jpg",
|
||||
expected: "/uploads/2026/09/17/1.jpg",
|
||||
},
|
||||
{
|
||||
name: "no base path, leading slash key",
|
||||
basePath: "",
|
||||
key: "/uploads/2026/09/17/1.jpg",
|
||||
expected: "/uploads/2026/09/17/1.jpg",
|
||||
},
|
||||
{
|
||||
name: "with base path, relative key (uploading)",
|
||||
basePath: "/DockerData/openflare_data_webdav",
|
||||
key: "uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "with base path, key with leading slash (uploading)",
|
||||
basePath: "/DockerData/openflare_data_webdav",
|
||||
key: "/uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "with base path, key already has base path (reading from legacy DB)",
|
||||
basePath: "/DockerData/openflare_data_webdav",
|
||||
key: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "with base path without leading slash, key already has base path",
|
||||
basePath: "DockerData/openflare_data_webdav",
|
||||
key: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "with base path with trailing slash, key already has base path",
|
||||
basePath: "/DockerData/openflare_data_webdav/",
|
||||
key: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "with base path, key already has double base path from previous bug",
|
||||
basePath: "/DockerData/openflare_data_webdav",
|
||||
key: "/DockerData/openflare_data_webdav/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "key with similar prefix name that is not a directory match",
|
||||
basePath: "/data",
|
||||
key: "/data_backup/uploads/1.jpg",
|
||||
expected: "/data/data_backup/uploads/1.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
backend, err := newWebDAVBackend(WebDAVConfig{
|
||||
Endpoint: "http://127.0.0.1:5005",
|
||||
BasePath: tt.basePath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newWebDAVBackend failed: %v", err)
|
||||
}
|
||||
actual := backend.targetPath(tt.key)
|
||||
if actual != tt.expected {
|
||||
t.Errorf("targetPath(%q) = %q, want %q", tt.key, actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAVRelKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
basePath string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "relative key remains relative",
|
||||
basePath: "/DockerData/openflare_data_webdav",
|
||||
key: "uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "leading slash key is stripped to relative",
|
||||
basePath: "/DockerData/openflare_data_webdav",
|
||||
key: "/uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "legacy key with basePath is stripped to relative",
|
||||
basePath: "/DockerData/openflare_data_webdav",
|
||||
key: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "corrupted key with duplicate basePath is stripped to relative",
|
||||
basePath: "/DockerData/openflare_data_webdav",
|
||||
key: "/DockerData/openflare_data_webdav/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
{
|
||||
name: "empty basePath preserves relative key",
|
||||
basePath: "",
|
||||
key: "/uploads/2026/09/17/105102954490499072.jpg",
|
||||
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
backend, err := newWebDAVBackend(WebDAVConfig{
|
||||
Endpoint: "http://127.0.0.1:5005",
|
||||
BasePath: tt.basePath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newWebDAVBackend failed: %v", err)
|
||||
}
|
||||
actual := backend.relKey(tt.key)
|
||||
if actual != tt.expected {
|
||||
t.Errorf("relKey(%q) = %q, want %q", tt.key, actual, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAVBackendRoundTrip(t *testing.T) {
|
||||
wdHandler := &webdav.Handler{
|
||||
FileSystem: webdav.NewMemFS(),
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
}
|
||||
server := httptest.NewServer(wdHandler)
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
basePath := "/DockerData/openflare_data_webdav"
|
||||
backend, err := newWebDAVBackend(WebDAVConfig{
|
||||
Endpoint: server.URL,
|
||||
BasePath: basePath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newWebDAVBackend failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Test connection
|
||||
if err := backend.Test(ctx); err != nil {
|
||||
t.Fatalf("backend.Test failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. Put object using relative key (typical upload flow)
|
||||
origContent := []byte("test image content 12345")
|
||||
objectKey := "uploads/2026/09/17/105102954490499072.jpg"
|
||||
putRes, err := backend.Put(ctx, objectKey, bytes.NewReader(origContent), int64(len(origContent)), "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("backend.Put failed: %v", err)
|
||||
}
|
||||
|
||||
// PutResult.Key MUST be the pure logical relative key, decoupled from basePath
|
||||
expectedLogicalKey := "uploads/2026/09/17/105102954490499072.jpg"
|
||||
if putRes.Key != expectedLogicalKey {
|
||||
t.Errorf("putRes.Key = %q, want %q", putRes.Key, expectedLogicalKey)
|
||||
}
|
||||
|
||||
// 3. Get object using logical key (new standard upload flow)
|
||||
obj, err := backend.Get(ctx, putRes.Key)
|
||||
if err != nil {
|
||||
t.Fatalf("backend.Get with putRes.Key failed: %v", err)
|
||||
}
|
||||
defer obj.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(obj.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(bodyBytes, origContent) {
|
||||
t.Errorf("read content = %q, want %q", string(bodyBytes), string(origContent))
|
||||
}
|
||||
|
||||
// 4. Get object using legacy key containing basePath (existing DB records from before fix)
|
||||
legacyKey := "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg"
|
||||
obj2, err := backend.Get(ctx, legacyKey)
|
||||
if err != nil {
|
||||
t.Fatalf("backend.Get with legacyKey failed: %v", err)
|
||||
}
|
||||
defer obj2.Body.Close()
|
||||
bodyBytes2, err := io.ReadAll(obj2.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(bodyBytes2, origContent) {
|
||||
t.Errorf("read content = %q, want %q", string(bodyBytes2), string(origContent))
|
||||
}
|
||||
|
||||
// 5. Get object using accidental double basePath (defensive recovery for corrupted DB records)
|
||||
doubledKey := "/DockerData/openflare_data_webdav/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg"
|
||||
obj3, err := backend.Get(ctx, doubledKey)
|
||||
if err != nil {
|
||||
t.Fatalf("backend.Get with doubledKey failed: %v", err)
|
||||
}
|
||||
defer obj3.Body.Close()
|
||||
bodyBytes3, err := io.ReadAll(obj3.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(bodyBytes3, origContent) {
|
||||
t.Errorf("read content = %q, want %q", string(bodyBytes3), string(origContent))
|
||||
}
|
||||
|
||||
// 6. Delete object using logical key
|
||||
if err := backend.Delete(ctx, putRes.Key); err != nil {
|
||||
t.Fatalf("backend.Delete failed: %v", err)
|
||||
}
|
||||
|
||||
// 7. Verify object is deleted
|
||||
_, err = backend.Get(ctx, putRes.Key)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error after delete, got nil")
|
||||
}
|
||||
|
||||
// 8. Put again, and delete using legacy key format
|
||||
_, err = backend.Put(ctx, objectKey, bytes.NewReader(origContent), int64(len(origContent)), "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("second backend.Put failed: %v", err)
|
||||
}
|
||||
if err := backend.Delete(ctx, legacyKey); err != nil {
|
||||
t.Fatalf("backend.Delete with legacyKey failed: %v", err)
|
||||
}
|
||||
_, err = backend.Get(ctx, objectKey)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error after delete with legacyKey, got nil")
|
||||
}
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE of_proxy_routes ADD COLUMN enable_http2 BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE of_proxy_routes DROP COLUMN enable_http2;
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE of_cf_pointing_groups
|
||||
ADD COLUMN target_mode VARCHAR(16) NOT NULL DEFAULT 'node',
|
||||
ADD COLUMN record_type VARCHAR(8) NOT NULL DEFAULT 'A',
|
||||
ADD COLUMN record_content VARCHAR(255) NOT NULL DEFAULT '';
|
||||
|
||||
INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
|
||||
VALUES (105, 'Cloudflare 节点故障回退检查', 'of_cloudflare_failover_check', '* * * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_schedules WHERE id = 105;
|
||||
ALTER TABLE of_cf_pointing_groups
|
||||
DROP COLUMN record_content,
|
||||
DROP COLUMN record_type,
|
||||
DROP COLUMN target_mode;
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
-- +goose Up
|
||||
UPDATE w_system_configs
|
||||
SET value = 'true', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'openresty_proxy_request_buffering_enabled' AND value = 'false';
|
||||
|
||||
-- +goose Down
|
||||
-- Intentionally left unchanged: restoring the old value could overwrite a later user choice.
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES ('log_db_migration', '', 'system', 0, '日志数据库切换冻结标记', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
-- Keep the runtime migration flag: this row may have existed before this migration.
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES ('openresty_trusted_proxy_cidrs', '["173.245.48.0/20","103.21.244.0/22","103.22.200.0/22","103.31.4.0/22","141.101.64.0/18","108.162.192.0/18","190.93.240.0/20","188.114.96.0/20","197.234.240.0/22","198.41.128.0/17","162.158.0.0/15","104.16.0.0/13","104.24.0.0/14","172.64.0.0/13","131.0.72.0/22","2400:cb00::/32","2606:4700::/32","2803:f800::/32","2405:b500::/32","2405:8100::/32","2a06:98c0::/29","2c0f:f248::/32"]', 'business', 0, '可信代理 IPv4/IPv6 CIDR JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key = 'openresty_trusted_proxy_cidrs';
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE of_proxy_routes ADD COLUMN enable_http2 BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE of_proxy_routes DROP COLUMN enable_http2;
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE of_cf_pointing_groups ADD COLUMN target_mode VARCHAR(16) NOT NULL DEFAULT 'node';
|
||||
ALTER TABLE of_cf_pointing_groups ADD COLUMN record_type VARCHAR(8) NOT NULL DEFAULT 'A';
|
||||
ALTER TABLE of_cf_pointing_groups ADD COLUMN record_content VARCHAR(255) NOT NULL DEFAULT '';
|
||||
|
||||
INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
|
||||
VALUES (105, 'Cloudflare 节点故障回退检查', 'of_cloudflare_failover_check', '* * * * *', '{}', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_schedules WHERE id = 105;
|
||||
ALTER TABLE of_cf_pointing_groups DROP COLUMN record_content;
|
||||
ALTER TABLE of_cf_pointing_groups DROP COLUMN record_type;
|
||||
ALTER TABLE of_cf_pointing_groups DROP COLUMN target_mode;
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
-- +goose Up
|
||||
UPDATE w_system_configs
|
||||
SET value = 'true', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'openresty_proxy_request_buffering_enabled' AND value = 'false';
|
||||
|
||||
-- +goose Down
|
||||
-- Intentionally left unchanged: restoring the old value could overwrite a later user choice.
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
-- +goose Up
|
||||
INSERT OR IGNORE INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES ('log_db_migration', '', 'system', 0, '日志数据库切换冻结标记', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
|
||||
-- +goose Down
|
||||
-- Keep the runtime migration flag: this row may have existed before this migration.
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
-- +goose Up
|
||||
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||
VALUES ('openresty_trusted_proxy_cidrs', '["173.245.48.0/20","103.21.244.0/22","103.22.200.0/22","103.31.4.0/22","141.101.64.0/18","108.162.192.0/18","190.93.240.0/20","188.114.96.0/20","197.234.240.0/22","198.41.128.0/17","162.158.0.0/15","104.16.0.0/13","104.24.0.0/14","172.64.0.0/13","131.0.72.0/22","2400:cb00::/32","2606:4700::/32","2803:f800::/32","2405:b500::/32","2405:8100::/32","2a06:98c0::/29","2c0f:f248::/32"]', 'business', 0, '可信代理 IPv4/IPv6 CIDR JSON 数组', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM w_system_configs WHERE key = 'openresty_trusted_proxy_cidrs';
|
||||
@@ -5,9 +5,6 @@ package migrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -15,7 +12,6 @@ import (
|
||||
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -27,62 +23,7 @@ import (
|
||||
// (初始系统配置 + 各期配置迁移/新增 seed:of_options 迁移、文件白名单、磁盘缓存、
|
||||
// 登录会话 TTL、升级源、存储、FRPS Web UI、Pages、OpenResty 限流、单 IP 限频、
|
||||
// 错误页、SW 离线、日志保留期、指标保留期等);新增配置 seed 迁移时需同步更新本常量。
|
||||
const expectedMigratedSystemConfigCount = 97
|
||||
|
||||
func TestTrustedProxyCIDRMigrationDefaultsMatchRenderer(t *testing.T) {
|
||||
want := openrestyrender.DefaultTrustedProxyCIDRs()
|
||||
if len(want) != 22 {
|
||||
t.Fatalf("renderer default contains %d proxy ranges, want 22", len(want))
|
||||
}
|
||||
for _, migrationPath := range []string{
|
||||
"goose/postgres/202610070005_add_openresty_trusted_proxy_cidrs.sql",
|
||||
"goose/sqlite/202610070005_add_openresty_trusted_proxy_cidrs.sql",
|
||||
} {
|
||||
migration, err := fs.ReadFile(migrationFS, migrationPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed, ok := strings.CutPrefix(string(migration), "-- +goose Up\nINSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)\nVALUES ('openresty_trusted_proxy_cidrs', '")
|
||||
if !ok {
|
||||
t.Fatalf("%s does not contain the expected CIDR seed", migrationPath)
|
||||
}
|
||||
seed, _, ok = strings.Cut(seed, "', 'business'")
|
||||
if !ok {
|
||||
t.Fatalf("%s CIDR seed value is malformed", migrationPath)
|
||||
}
|
||||
var got []string
|
||||
if err := json.Unmarshal([]byte(seed), &got); err != nil {
|
||||
t.Fatalf("decode %s CIDR seed: %v", migrationPath, err)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s CIDR seed differs from renderer default", migrationPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseMigrationVersionsAreUniquePerDialect(t *testing.T) {
|
||||
for _, dir := range []string{"goose/postgres", "goose/sqlite"} {
|
||||
files, err := fs.Glob(migrationFS, dir+"/*.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("glob %s migrations: %v", dir, err)
|
||||
}
|
||||
|
||||
versions := make(map[string]string, len(files))
|
||||
for _, file := range files {
|
||||
name := strings.TrimPrefix(file, dir+"/")
|
||||
version, _, ok := strings.Cut(name, "_")
|
||||
if !ok {
|
||||
t.Errorf("migration %q does not include a version prefix", file)
|
||||
continue
|
||||
}
|
||||
if previous, exists := versions[version]; exists {
|
||||
t.Errorf("%s has duplicate migration version %s in %q and %q", dir, version, previous, name)
|
||||
continue
|
||||
}
|
||||
versions[version] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
const expectedMigratedSystemConfigCount = 95
|
||||
|
||||
func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
|
||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
@@ -122,25 +63,6 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
|
||||
if systemConfigCount != expectedMigratedSystemConfigCount {
|
||||
t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, expectedMigratedSystemConfigCount)
|
||||
}
|
||||
var trustedProxyConfig model.SystemConfig
|
||||
if err := sqliteDB.Where("key = ?", model.ConfigKeyOpenRestyTrustedProxyCIDRs).First(&trustedProxyConfig).Error; err != nil {
|
||||
t.Fatalf("Migrate() trusted proxy CIDR config error = %v", err)
|
||||
}
|
||||
var trustedProxyCIDRs []string
|
||||
if err := json.Unmarshal([]byte(trustedProxyConfig.Value), &trustedProxyCIDRs); err != nil {
|
||||
t.Fatalf("decode trusted proxy CIDR config: %v", err)
|
||||
}
|
||||
if !slices.Equal(trustedProxyCIDRs, openrestyrender.DefaultTrustedProxyCIDRs()) {
|
||||
t.Errorf("Migrate() trusted proxy CIDRs = %#v, want default Cloudflare ranges", trustedProxyCIDRs)
|
||||
}
|
||||
|
||||
var logMigrationConfig model.SystemConfig
|
||||
if err := sqliteDB.Where("key = ?", model.ConfigKeyLogDBMigration).First(&logMigrationConfig).Error; err != nil {
|
||||
t.Fatalf("Migrate() log migration config error = %v", err)
|
||||
}
|
||||
if logMigrationConfig.Value != "" || logMigrationConfig.Type != "system" || logMigrationConfig.Visibility != model.ConfigVisibilityHidden {
|
||||
t.Errorf("Migrate() log migration config = %+v, want an empty hidden system config", logMigrationConfig)
|
||||
}
|
||||
|
||||
var adminCount int64
|
||||
if err := sqliteDB.Table("w_users").Where("username = ?", "admin").Count(&adminCount).Error; err != nil {
|
||||
|
||||
@@ -60,8 +60,6 @@ func Register() {
|
||||
task.RegisterTaskMeta(cf.SyncGroupMeta)
|
||||
task.RegisterHandler(cf.SyncByNodeTask, &cf.SyncByNodeTaskHandler{})
|
||||
task.RegisterTaskMeta(cf.SyncByNodeMeta)
|
||||
task.RegisterHandler(cf.FailoverCheckTask, &cf.FailoverCheckTaskHandler{})
|
||||
task.RegisterTaskMeta(cf.FailoverCheckMeta)
|
||||
|
||||
// pages source actions are only dispatched by the Pages domain API/scanner.
|
||||
task.RegisterHandler(pages.PagesSourceScanTask, &pages.SourceScanHandler{})
|
||||
|
||||
@@ -24,11 +24,6 @@ const (
|
||||
CFMemberSyncOK = "ok"
|
||||
// CFMemberSyncError indicates the latest reconciliation failed.
|
||||
CFMemberSyncError = "error"
|
||||
|
||||
// CFPointingTargetModeNode binds group members to a managed edge node.
|
||||
CFPointingTargetModeNode = "node"
|
||||
// CFPointingTargetModeCustom binds group members to a custom DNS target.
|
||||
CFPointingTargetModeCustom = "custom"
|
||||
)
|
||||
|
||||
// CFConnection stores the single Cloudflare API credential source.
|
||||
@@ -50,9 +45,6 @@ func (CFConnection) TableName() string { return "of_cf_connections" }
|
||||
type CFPointingGroup struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
TargetMode string `json:"target_mode" gorm:"size:16;not null;default:'node'"`
|
||||
RecordType string `json:"record_type" gorm:"size:8;not null;default:'A'"`
|
||||
RecordContent string `json:"record_content" gorm:"size:255;not null;default:''"`
|
||||
PrimaryNodeID uint `json:"primary_node_id" gorm:"not null;index:idx_of_cf_pointing_groups_primary_node_id"`
|
||||
BackupNodeID *uint `json:"backup_node_id" gorm:"index:idx_of_cf_pointing_groups_backup_node_id"`
|
||||
ActiveNodeID uint `json:"active_node_id" gorm:"not null;index:idx_of_cf_pointing_groups_active_node_id"`
|
||||
|
||||
@@ -48,7 +48,6 @@ http {
|
||||
client_header_timeout {{OpenRestyClientHeaderTimeout}};
|
||||
client_body_timeout {{OpenRestyClientBodyTimeout}};
|
||||
client_max_body_size {{OpenRestyClientMaxBodySize}};
|
||||
{{OpenRestyRealIPDirectives}}
|
||||
large_client_header_buffers {{OpenRestyLargeClientHeaderBuffers}};
|
||||
send_timeout {{OpenRestySendTimeout}};
|
||||
proxy_connect_timeout {{OpenRestyProxyConnectTimeout}};
|
||||
|
||||
@@ -18,7 +18,6 @@ type ProxyRoute struct {
|
||||
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
|
||||
EnableHTTP2 bool `json:"enable_http2" gorm:"not null;default:true"`
|
||||
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
|
||||
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
|
||||
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
|
||||
|
||||
@@ -103,7 +103,6 @@ const (
|
||||
ConfigKeyOpenRestyCacheLockTimeout = "openresty_cache_lock_timeout" // 缓存锁超时
|
||||
ConfigKeyOpenRestyCacheUseStale = "openresty_cache_use_stale" // 缓存失效策略
|
||||
ConfigKeyOpenRestyMainConfigTemplate = "openresty_main_config_template" // 主配置模板
|
||||
ConfigKeyOpenRestyTrustedProxyCIDRs = "openresty_trusted_proxy_cidrs" // 可信代理 CIDR JSON 数组
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
|
||||
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
|
||||
|
||||
+16
-1
@@ -6,6 +6,7 @@ package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,8 +15,8 @@ import (
|
||||
)
|
||||
|
||||
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
|
||||
// 第三方身份使用 Sub;忽略非标准 id 字段,本地用户 ID 独立生成。
|
||||
type OAuthUserInfo struct {
|
||||
ID uint64 `json:"id"`
|
||||
Sub string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
@@ -25,6 +26,20 @@ type OAuthUserInfo struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
// GetID 获取用户 ID
|
||||
func (u *OAuthUserInfo) GetID() uint64 {
|
||||
if u.ID != 0 {
|
||||
return u.ID
|
||||
}
|
||||
// 从 sub 解析(OIDC 格式)
|
||||
if u.Sub != "" {
|
||||
if id, err := strconv.ParseUint(u.Sub, 10, 64); err == nil {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// User 用户表实体
|
||||
type User struct {
|
||||
ID uint64 `json:"id,string" gorm:"primaryKey;not null"`
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOAuthUserInfoIgnoresProviderID verifies nonstandard IDs cannot affect OIDC identity parsing.
|
||||
func TestOAuthUserInfoIgnoresProviderID(t *testing.T) {
|
||||
for _, providerID := range []string{`"a27dfc56-07ae-4c9d-9e5c-99103bd8805f"`, `"88888"`, `88888`, `null`} {
|
||||
t.Run(providerID, func(t *testing.T) {
|
||||
payload := `{"id":` + providerID + `,"sub":"external-subject","preferred_username":"oidc-user","email":"oidc@example.com","name":"OIDC User"}`
|
||||
var info OAuthUserInfo
|
||||
if err := json.Unmarshal([]byte(payload), &info); err != nil {
|
||||
t.Fatalf("json.Unmarshal(OAuthUserInfo, id=%s) error = %v, want nil", providerID, err)
|
||||
}
|
||||
want := OAuthUserInfo{
|
||||
Sub: "external-subject",
|
||||
PreferredUsername: "oidc-user",
|
||||
Email: "oidc@example.com",
|
||||
Name: "OIDC User",
|
||||
}
|
||||
if info != want {
|
||||
t.Errorf("json.Unmarshal(OAuthUserInfo, id=%s) = %+v, want %+v", providerID, info, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,6 @@ func ListCFPointingGroups(ctx context.Context) ([]model.CFPointingGroup, error)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// UpdateCFPointingGroupTarget updates the active node or custom-record target.
|
||||
func UpdateCFPointingGroupTarget(ctx context.Context, groupID uint, changes map[string]any) error {
|
||||
return db.DB(ctx).Model(&model.CFPointingGroup{}).Where("id = ?", groupID).Updates(changes).Error
|
||||
}
|
||||
|
||||
// GetCFPointingGroup returns a group by ID.
|
||||
func GetCFPointingGroup(ctx context.Context, id uint) (*model.CFPointingGroup, error) {
|
||||
var item model.CFPointingGroup
|
||||
@@ -197,12 +192,9 @@ func GetCFPointingMemberContext(ctx context.Context, memberID uint) (*CFPointing
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := &model.OpenFlareNode{}
|
||||
if group.TargetMode != model.CFPointingTargetModeCustom {
|
||||
node, err = GetOpenFlareNodeByID(ctx, group.ActiveNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, err := GetOpenFlareNodeByID(ctx, group.ActiveNodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CFPointingMemberContext{Member: *member, Group: *group, Domain: *domain, Zone: *zone, Node: *node}, nil
|
||||
}
|
||||
@@ -222,15 +214,6 @@ func MarkCFPointingGroupMembersPending(ctx context.Context, groupID uint) error
|
||||
Updates(map[string]any{"sync_status": model.CFMemberSyncPending, "last_error": ""}).Error
|
||||
}
|
||||
|
||||
// HasPendingCFPointingGroupMembers reports whether a group has members awaiting reconciliation.
|
||||
func HasPendingCFPointingGroupMembers(ctx context.Context, groupID uint) (bool, error) {
|
||||
var count int64
|
||||
err := db.DB(ctx).Model(&model.CFPointingMember{}).
|
||||
Where("group_id = ? AND sync_status = ?", groupID, model.CFMemberSyncPending).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// DeleteCFPointingGroupAndMembers removes a group after its remote records are deleted.
|
||||
func DeleteCFPointingGroupAndMembers(ctx context.Context, groupID uint) error {
|
||||
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
@@ -199,11 +199,11 @@ func UpdateUser(ctx context.Context, user *model.User) error {
|
||||
}
|
||||
|
||||
// CreateUserFromOAuth creates a user from OAuth profile data and fills userOut.
|
||||
// The local ID is generated independently of the provider's identity claims.
|
||||
func CreateUserFromOAuth(ctx context.Context, userOut *model.User, oauthInfo *model.OAuthUserInfo) error {
|
||||
now := time.Now()
|
||||
userID := oauthInfo.GetID()
|
||||
newUser := model.User{
|
||||
ID: idgen.NextUint64ID(),
|
||||
ID: userID,
|
||||
Username: oauthInfo.Username,
|
||||
Nickname: oauthInfo.Name,
|
||||
Email: oauthInfo.Email,
|
||||
@@ -212,6 +212,9 @@ func CreateUserFromOAuth(ctx context.Context, userOut *model.User, oauthInfo *mo
|
||||
LastLoginAt: now,
|
||||
IsAdmin: false,
|
||||
}
|
||||
if newUser.ID == 0 {
|
||||
newUser.ID = idgen.NextUint64ID()
|
||||
}
|
||||
if err := db.DB(ctx).Create(&newUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,49 +8,27 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
//nolint:unused // Used by frontend.go in embed_frontend builds; default lint runs without that tag.
|
||||
var nextExportDynamicPrefixes = []string{"websites", "cloudflare/groups"}
|
||||
|
||||
//nolint:unused // Used by frontend.go in embed_frontend builds; default lint runs without that tag.
|
||||
func resolveNextExportDynamicFallback(subFS fs.FS, cleanPath string) (string, bool) {
|
||||
templatePath, ok := nextExportDynamicTemplate(cleanPath)
|
||||
if !ok {
|
||||
parts := strings.Split(cleanPath, "/")
|
||||
if len(parts) < 2 || parts[0] != "websites" || parts[1] == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var templatePath string
|
||||
switch {
|
||||
case len(parts) == 2 && !strings.Contains(parts[1], "."):
|
||||
templatePath = "websites/1.html"
|
||||
case len(parts) == 2 && strings.HasSuffix(parts[1], ".txt"):
|
||||
templatePath = "websites/1.txt"
|
||||
case len(parts) == 3 && parts[2] != "" && strings.HasPrefix(parts[2], "__next.") && strings.HasSuffix(parts[2], ".txt"):
|
||||
templatePath = "websites/1/" + parts[2]
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, err := fs.Stat(subFS, templatePath); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return templatePath, true
|
||||
}
|
||||
|
||||
//nolint:unused // Used by frontend.go in embed_frontend builds; default lint runs without that tag.
|
||||
func nextExportDynamicTemplate(cleanPath string) (string, bool) {
|
||||
for _, prefix := range nextExportDynamicPrefixes {
|
||||
if path, ok := nextExportIDTemplate(cleanPath, prefix); ok {
|
||||
return path, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
//nolint:unused // Used by frontend.go in embed_frontend builds; default lint runs without that tag.
|
||||
func nextExportIDTemplate(cleanPath, prefix string) (string, bool) {
|
||||
if cleanPath == prefix || !strings.HasPrefix(cleanPath, prefix+"/") {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimPrefix(cleanPath, prefix+"/")
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Split(rest, "/")
|
||||
switch {
|
||||
case len(parts) == 1 && !strings.Contains(parts[0], "."):
|
||||
return prefix + "/1.html", true
|
||||
case len(parts) == 1 && strings.HasSuffix(parts[0], ".txt"):
|
||||
return prefix + "/1.txt", true
|
||||
case len(parts) == 2 && parts[1] != "" && strings.HasPrefix(parts[1], "__next.") && strings.HasSuffix(parts[1], ".txt"):
|
||||
return prefix + "/1/" + parts[1], true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ func TestResolveNextExportDynamicFallbackUsesZoneTemplate(t *testing.T) {
|
||||
"websites/1.html": &fstest.MapFile{Data: []byte("zone detail")},
|
||||
"websites/1.txt": &fstest.MapFile{Data: []byte("zone flight")},
|
||||
"websites/1/__next.!KG1haW4p.websites.$d$zoneId.__PAGE__.txt": &fstest.MapFile{Data: []byte("zone segment")},
|
||||
"cloudflare/groups.html": &fstest.MapFile{Data: []byte("groups list redirect")},
|
||||
"cloudflare/groups/1.html": &fstest.MapFile{Data: []byte("group detail")},
|
||||
"cloudflare/groups/1.txt": &fstest.MapFile{Data: []byte("group flight")},
|
||||
"cloudflare/groups/1/__next.!KG1haW4p.cloudflare.groups.$d$id.__PAGE__.txt": &fstest.MapFile{Data: []byte("group segment")},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -32,22 +28,15 @@ func TestResolveNextExportDynamicFallbackUsesZoneTemplate(t *testing.T) {
|
||||
input: "websites/3/__next.!KG1haW4p.websites.$d$zoneId.__PAGE__.txt",
|
||||
want: "websites/1/__next.!KG1haW4p.websites.$d$zoneId.__PAGE__.txt",
|
||||
},
|
||||
{name: "cloudflare group html", input: "cloudflare/groups/2", want: "cloudflare/groups/1.html"},
|
||||
{name: "cloudflare group route payload", input: "cloudflare/groups/2.txt", want: "cloudflare/groups/1.txt"},
|
||||
{
|
||||
name: "cloudflare group segment payload",
|
||||
input: "cloudflare/groups/3/__next.!KG1haW4p.cloudflare.groups.$d$id.__PAGE__.txt",
|
||||
want: "cloudflare/groups/1/__next.!KG1haW4p.cloudflare.groups.$d$id.__PAGE__.txt",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := resolveNextExportDynamicFallback(subFS, tt.input)
|
||||
if !ok {
|
||||
t.Fatalf("resolveNextExportDynamicFallback(%q) ok = false, want true", tt.input)
|
||||
t.Fatal("expected dynamic websites route fallback")
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("resolveNextExportDynamicFallback(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
t.Fatalf("expected %q fallback, got %q", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -64,13 +53,6 @@ func TestResolveNextExportDynamicFallbackRejectsNestedOrAssetPath(t *testing.T)
|
||||
"websites/3.js",
|
||||
"websites/3/",
|
||||
"websites/3/missing.txt",
|
||||
"cloudflare",
|
||||
"cloudflare/groups",
|
||||
"cloudflare/groups.html",
|
||||
"cloudflare/groups/2/settings",
|
||||
"cloudflare/groups/2.js",
|
||||
"cloudflare/groups/2/",
|
||||
"cloudflare/groups/2/missing.txt",
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt, func(t *testing.T) {
|
||||
@@ -87,9 +69,6 @@ func TestResolveNextExportDynamicFallbackRequiresGeneratedTemplate(t *testing.T)
|
||||
}
|
||||
|
||||
if got, ok := resolveNextExportDynamicFallback(subFS, "websites/3"); ok {
|
||||
t.Fatalf("resolveNextExportDynamicFallback(%q) = %q, want no fallback", "websites/3", got)
|
||||
}
|
||||
if got, ok := resolveNextExportDynamicFallback(subFS, "cloudflare/groups/2"); ok {
|
||||
t.Fatalf("resolveNextExportDynamicFallback(%q) = %q, want no fallback", "cloudflare/groups/2", got)
|
||||
t.Fatalf("expected no fallback without generated template, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,6 @@ func registerCloudflareRoutes(apiGroup *gin.RouterGroup) {
|
||||
route.GET("/groups/:id/members", cf.ListMembersHandler)
|
||||
route.POST("/groups/:id/members", cf.CreateMemberHandler)
|
||||
route.POST("/groups/:id/members/:memberId/update", cf.UpdateMemberHandler)
|
||||
route.POST("/groups/:id/members/:memberId/move", cf.MoveMemberHandler)
|
||||
route.POST("/groups/:id/members/:memberId/remove", cf.RemoveMemberHandler)
|
||||
route.POST("/groups/:id/members/:memberId/sync", cf.SyncMemberHandler)
|
||||
route.POST("/groups/:id/members/batch-move", cf.BatchMoveMembersHandler)
|
||||
route.POST("/groups/:id/members/batch-remove", cf.BatchRemoveMembersHandler)
|
||||
route.POST("/groups/:id/members/batch-proxy", cf.BatchEnableProxyHandler)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
@@ -171,18 +170,7 @@ func DedupeSupportFiles(files []SupportFile) []SupportFile {
|
||||
}
|
||||
|
||||
func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot, limitReqRates []string) string {
|
||||
trustedProxyCIDRs := cfg.TrustedProxyCIDRs
|
||||
if trustedProxyCIDRs == nil {
|
||||
trustedProxyCIDRs = DefaultTrustedProxyCIDRs()
|
||||
}
|
||||
trustedProxyDirectives := renderTrustedProxyDirectives(trustedProxyCIDRs, !hasNginxDirective(templateText, "real_ip_header"))
|
||||
if !strings.Contains(templateText, RealIPDirectivesPlaceholder) && trustedProxyDirectives != "" {
|
||||
// This required token expands to a complete map block in the http context.
|
||||
// Injecting before it avoids depending on formatting or splitting directives.
|
||||
templateText = strings.Replace(templateText, "{{OpenRestyConnectionUpgradeMap}}", trustedProxyDirectives+"{{OpenRestyConnectionUpgradeMap}}", 1)
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
RealIPDirectivesPlaceholder, trustedProxyDirectives,
|
||||
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
|
||||
"{{OpenRestyWorkerConnections}}", strconv.Itoa(cfg.WorkerConnections),
|
||||
"{{OpenRestyWorkerRlimitNofile}}", strconv.Itoa(cfg.WorkerRlimitNofile),
|
||||
@@ -217,37 +205,6 @@ func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot, limitReqR
|
||||
return replacer.Replace(templateText)
|
||||
}
|
||||
|
||||
func hasNginxDirective(templateText string, directive string) bool {
|
||||
for _, line := range strings.Split(templateText, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if fields := strings.Fields(line); len(fields) > 0 && fields[0] == directive {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderTrustedProxyDirectives(cidrs []string, includeHeader bool) string {
|
||||
if len(cidrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
if includeHeader {
|
||||
builder.WriteString(" real_ip_header CF-Connecting-IP;\n real_ip_recursive on;\n")
|
||||
}
|
||||
for _, cidr := range cidrs {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&builder, " set_real_ip_from %s;\n", prefix.Masked())
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func renderTemplateDirective(enabled bool, statement string) string {
|
||||
if !enabled {
|
||||
return ""
|
||||
@@ -377,42 +334,34 @@ func renderHTTPRedirectServer(serverNames string) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", serverNames)
|
||||
}
|
||||
|
||||
func renderHTTPSServer(serverNames string, siteName string, originURL string, originHost string, certificateID uint, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, http2Enabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, swEnabled bool, cfg ConfigSnapshot) 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, swEnabled bool, cfg ConfigSnapshot) string {
|
||||
certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID)
|
||||
keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID)
|
||||
var h3Listen string
|
||||
var h3Header string
|
||||
var http2Directive string
|
||||
if http2Enabled {
|
||||
http2Directive = " http2 on;\n"
|
||||
}
|
||||
if cfg.HTTP3Enabled {
|
||||
h3Listen = " listen 443 quic;\n"
|
||||
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
||||
}
|
||||
if swEnabled {
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s%s server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s%s}\n\n", h3Listen, http2Directive, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled), renderServiceWorkerChallenger(cfg))
|
||||
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%s }\n%s%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled), renderServiceWorkerChallenger(cfg))
|
||||
}
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s%s server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s}\n\n", h3Listen, http2Directive, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled))
|
||||
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%s }\n%s%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), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, http2Enabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, swEnabled bool, cfg ConfigSnapshot) string {
|
||||
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, swEnabled bool, cfg ConfigSnapshot) string {
|
||||
certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID)
|
||||
keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID)
|
||||
var h3Listen string
|
||||
var h3Header string
|
||||
var http2Directive string
|
||||
if http2Enabled {
|
||||
http2Directive = " http2 on;\n"
|
||||
}
|
||||
if cfg.HTTP3Enabled {
|
||||
h3Listen = " listen 443 quic;\n"
|
||||
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
||||
}
|
||||
if swEnabled {
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s%s server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s%s}\n\n", h3Listen, http2Directive, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled), renderServiceWorkerChallenger(cfg))
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled), renderServiceWorkerChallenger(cfg))
|
||||
}
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s%s server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, http2Directive, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
func renderPagesRootLocationBlock(deployment *PagesDeployment, limitConfig routeLimitConfig, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user