Compare commits

...

6 Commits

Author SHA1 Message Date
ryan 217a71662f chore(release): v3.5.10
### ✨ 新功能
- 域名列表与快捷新增支持 `*.example.com`、`*.s3.example.com` 等通配符域名,可绑定站点规则并随配置版本下发至 CDN 节点;Cloudflare 指向分组可为其同步 A、AAAA 或 CNAME 记录。
- 域名流量统计支持聚合通配符匹配的实际子域名,重叠域名不会重复计数;Uptime Kuma 优先使用具体域名,只有通配符时跳过监控创建或更新并记录原因。
- 链路追踪新增 OTLP/HTTP 协议导出,可通过标准环境变量或系统配置设置导出协议、接收端地址和自定义请求头,与原有 OTLP/gRPC 导出协议并行。
- Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。
2026-11-09 10:53:34 +08:00
Deqiying 9a51a68206 feat(domain): 支持通配符域名及关联功能适配 (#63)
* feat(domain): 支持通配符域名及关联功能适配

* fix(domain): preserve legacy Unicode imports and address review findings

* fix(domain): canonicalize legacy IDNs and exclude empty host labels

* fix(domain): normalize equivalent Unicode IDN spellings

---------

Co-authored-by: Ryan <63696351+Rain-kl@users.noreply.github.com>
Co-authored-by: ryan <ryan@arctel.net>
2026-11-09 10:31:03 +08:00
ryan 8d8b3e1ea8 feat(otel): support standard OTLP/HTTP trace export (#64) 2026-11-09 10:25:16 +08:00
ryan acd63ff410 fix(otel): resolve exporter protocol through startup configuration 2026-11-09 10:23:47 +08:00
Arthur Koba b281b65e78 feat(otel): support standard OTLP/HTTP trace export 2026-11-08 23:24:00 +03:00
Deqiying e0ba758afa feat(cloudflare): 支持分组级自动与手动 TTL 配置 (#62) 2026-11-08 22:07:26 +08:00
66 changed files with 2619 additions and 131 deletions
+1
View File
@@ -95,6 +95,7 @@ worker:
# ─── OpenTelemetry Tracing ────────────────────────────────────────────────────── # ─── OpenTelemetry Tracing ──────────────────────────────────────────────────────
otel: otel:
export_protocol: "grpc" # grpc 或 http/protobuf;OTEL_EXPORTER_OTLP_TRACES_PROTOCOL 优先于通用 OTLP 协议变量
sampling_rate: 0.0 # Trace sampling rate (0.0 – 1.0) sampling_rate: 0.0 # Trace sampling rate (0.0 – 1.0)
tracer_name: "github.com/Rain-kl/OpenFlare" # Global tracer instrumentation name tracer_name: "github.com/Rain-kl/OpenFlare" # Global tracer instrumentation name
+9
View File
@@ -17,6 +17,15 @@ sidebar: false
## [Unreleased] ## [Unreleased]
## [v3.5.10] - 2026-11-09
### ✨ 新功能
- 域名列表与快捷新增支持 `*.example.com`、`*.s3.example.com` 等通配符域名,可绑定站点规则并随配置版本下发至 CDN 节点;Cloudflare 指向分组可为其同步 A、AAAA 或 CNAME 记录。
- 域名流量统计支持聚合通配符匹配的实际子域名,重叠域名不会重复计数;Uptime Kuma 优先使用具体域名,只有通配符时跳过监控创建或更新并记录原因。
- 链路追踪新增 OTLP/HTTP 协议导出,可通过标准环境变量或系统配置设置导出协议、接收端地址和自定义请求头,与原有 OTLP/gRPC 导出协议并行。
- Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。
## [v3.5.9] - 2026-11-07 ## [v3.5.9] - 2026-11-07
### ✨ 新功能 ### ✨ 新功能
+16
View File
@@ -272,6 +272,22 @@ docker compose up -d
``` ```
启动后可以通过访问 `http://localhost:16686` 打开 Jaeger 监控端查看系统 Span 链路。 启动后可以通过访问 `http://localhost:16686` 打开 Jaeger 监控端查看系统 Span 链路。
### 自定义 OpenTelemetry 接收端
链路追踪默认使用 OTLP/gRPC。对于支持 OTLP/HTTP protobuf 的接收端,可在 Server 容器的环境变量中配置:
```yaml
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT: https://collector.example.com
OTEL_SAMPLING_RATE: "0.1"
```
标准 SDK 会在通用 `OTEL_EXPORTER_OTLP_ENDPOINT` 的路径后追加 `/v1/traces`;例如上述配置发送到 `https://collector.example.com/v1/traces`。如果接收端使用自定义路径,请设置完整的 `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,SDK 将直接使用该 URL,不再追加路径。链路专用的 `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` 优先于通用协议配置;两者均未配置时使用 `otel.export_protocol`,缺省为 `grpc`。协议在启动配置层统一解析,再传入追踪组件。
需要鉴权或其他请求头时,使用可选的 `OTEL_EXPORTER_OTLP_HEADERS`,格式为 `key=value,key2=value2`,值按标准进行百分号编码,例如 `X-API-Key=example-key,X-Client-Name=example%20client`。链路专用的 `OTEL_EXPORTER_OTLP_TRACES_HEADERS` 优先于通用请求头配置。应用不预设鉴权方案;实际请求头与凭据由部署环境提供,不要将真实凭据写入仓库。
协议、地址和请求头由 OpenTelemetry SDK 按[标准 OTLP 配置](https://opentelemetry.io/docs/specs/otel/protocol/exporter/)处理,不依赖特定部署平台或观测后端。
--- ---
## 首次登录 ## 首次登录
+24 -6
View File
@@ -2,9 +2,9 @@
## 目标 ## 目标
通过 Cloudflare API 将 OpenFlare 中的 **ZoneDomain(明确 FQDN)** 快速指向边缘节点 IP,替代在 CF 控制台手工改 A 记录。用户以 **指向分组** 组织域名:每组配置主节点与备用节点、默认橙云策略;成员可单独覆盖橙云。系统以库表为期望状态,幂等同步远端 DNS。 通过 Cloudflare API 将 OpenFlare 中的 **ZoneDomain(具体或通配符 FQDN)** 快速指向边缘节点 IP 或自定义 DNS 目标。用户以 **指向分组** 组织域名:节点模式配置主节点与备用节点,自定义模式配置 A/AAAA/CNAME 的记录内容;成员可单独覆盖橙云。系统以库表为期望状态,幂等同步远端 DNS。
本模块是 **可选对接能力**,不把 Zone 本身变成权威 DNS 控制面。Zone 仍只负责根域边界、域名、证书与反代关联;DNS A 记录的创建/更新/删除由本模块驱动 Cloudflare。 本模块是 **可选对接能力**,不把 Zone 本身变成权威 DNS 控制面。Zone 仍只负责根域边界、域名、证书与反代关联;DNS A/AAAA/CNAME 记录的创建/更新/删除由本模块驱动 Cloudflare。
## 范围与分期 ## 范围与分期
@@ -14,7 +14,7 @@
* 连接配置:从现有 DNS 账号导入 **或** 模块内独立录入(混合来源),加密存储 * 连接配置:从现有 DNS 账号导入 **或** 模块内独立录入(混合来源),加密存储
* 指向分组 CRUD:主节点、备用节点(预留)、分组默认橙云 * 指向分组 CRUD:主节点、备用节点(预留)、分组默认橙云
* 成员管理:以 `zone_domain_id` 为粒度加入/移出;成员级橙云 * 成员管理:以 `zone_domain_id` 为粒度加入/移出;成员级橙云
* 同步:将每个成员写成 Cloudflare 上 **单条 A 记录** → 当前生效节点 IPv4 * 同步:节点模式写入 **单条 A 记录** → 当前生效节点 IPv4;自定义模式按分组设置写入 A、AAAA 或 CNAME 记录。
* 触发:手动同步、加入成员、改节点/橙云、节点 IP 变更入队 * 触发:手动同步、加入成员、改节点/橙云、节点 IP 变更入队
* 异步任务批量同步;成员同步状态与可读错误 * 异步任务批量同步;成员同步状态与可读错误
@@ -26,11 +26,18 @@
### 明确不做(更远或永久) ### 明确不做(更远或永久)
* 多 Cloudflare 账号并行(全局一份连接配置) * 多 Cloudflare 账号并行(全局一份连接配置)
* AAAA / 多 A 负载 / CNAME 到节点主机名 * 节点模式的 IPv6 地址跟随 / 多 A 负载 / CNAME 到节点主机名(自定义 AAAA/CNAME 不受此限制)
* 管理 MX/TXT/Page Rules 等非本模块 A 记录 * 管理 MX/TXT/Page Rules 等其他记录或规则
* 非 Cloudflare DNS 厂商 * 非 Cloudflare DNS 厂商
* 将 DNS 记录管理并入 Zone 核心模型 * 将 DNS 记录管理并入 Zone 核心模型
### 通配符域名
* 先在 Zone 域名列表登记 `*.example.com` 或 `*.s3.example.com`,再在分组成员选择器中添加;可用域名、成员归属与同步状态沿用具体域名的规则。
* 节点模式创建 A 记录;需要 AAAA 或 CNAME 时选择自定义模式,分别填写 IPv6 地址或具体目标主机名。
* Cloudflare Zone 查询仍使用注册根域(例如 `example.com`);DNS 记录的 `name` 保留完整通配符名称。创建、更新、查询与删除共用原有同步路径,不能把 `*.` 去掉后作为记录名。
* DNS-only / 橙云选项沿用分组默认值及成员覆盖值。DNS 同步成功不代表代理配置已生效,仍需绑定站点路由并发布激活配置;HTTPS 还需匹配的证书。
## 与现有能力的关系 ## 与现有能力的关系
| 现有能力 | 关系 | | 现有能力 | 关系 |
@@ -104,6 +111,7 @@ erDiagram
| `backup_node_id` | 备用(可空;一期仅存储) | | `backup_node_id` | 备用(可空;一期仅存储) |
| `active_node_id` | 当前生效节点;一期等于 primary;二期 failover 改写 | | `active_node_id` | 当前生效节点;一期等于 primary;二期 failover 改写 |
| `default_proxied` | 分组默认橙云;**仅影响新加入成员** | | `default_proxied` | 分组默认橙云;**仅影响新加入成员** |
| `ttl` | 分组级 TTL:`1` 为自动,手动填写 `60~86400` 的整数秒;仅对 DNS-only 成员生效,无成员级覆盖 |
| `enabled` | 是否参与同步 | | `enabled` | 是否参与同步 |
约束:主备不得为同一节点;选作生效目标的节点须有合法 IPv4。 约束:主备不得为同一节点;选作生效目标的节点须有合法 IPv4。
@@ -140,10 +148,20 @@ OpenFlare 库表为 Source of Truth。每个成员期望:
| name | ZoneDomain 的 FQDN | | name | ZoneDomain 的 FQDN |
| content | 分组 `active_node` 的 IPv4 | | content | 分组 `active_node` 的 IPv4 |
| proxied | 成员 `proxied` | | proxied | 成员 `proxied` |
| ttl | 橙云开启时由 CF 强制 Auto;关闭时使用统一默认(如 300) | | ttl | 橙云成员始终传 `1`(自动);DNS-only 成员使用分组 `ttl`,升级前分组继续使用 300 秒 |
一期不写 AAAA。节点 IP 非合法 IPv4 → 该成员 `error`。 一期不写 AAAA。节点 IP 非合法 IPv4 → 该成员 `error`。
### 分组 TTL
节点绑定和自定义 A / AAAA / CNAME 共用分组 TTL。在分组创建或编辑弹窗选择“自动”或“手动”;手动模式填写 `60~86400` 的整数秒,不接受空值、小数或越界值。本模块采用所有 Cloudflare 套餐通用的范围,暂不开放仅 Enterprise 支持的 30~59 秒。
* 自动通过 Cloudflare API 的 `ttl: 1` 表示,不是 1 秒;Cloudflare 当前的 Auto 为 300 秒,因此切换到自动并不会缩短缓存时间。
* 成员实际 `proxied=true` 时始终使用自动;分组 TTL 只控制灰云成员,分组默认橙云不影响 TTL 是否可编辑。
* 修改 TTL 后,已有成员标记为待同步;启用的分组沿用现有整组任务自动同步,停用的分组暂不下发。成员移入另一分组后使用目标分组的 TTL。
* 新建分组省略 `ttl` 时默认自动;更新省略 `ttl` 时保留原值。分组查询始终返回可直接回填的 `ttl`(自动或手动秒数)。
* PostgreSQL / SQLite 为分组表新增 `ttl`,默认零值仅用于兼容旧数据,业务层将其解释为 300 秒,不向 Cloudflare 发送 `0`;不新增成员 TTL 字段,也不批量改写已有成员。回滚迁移会删除该字段及已保存的 TTL 配置。
### 触发 ### 触发
| 触发 | 行为 | | 触发 | 行为 |
+13 -3
View File
@@ -10,7 +10,7 @@
* Zone 根域使用 Public Suffix List 解析,例如 `api.example.co.uk` 归属 `example.co.uk`。 * Zone 根域使用 Public Suffix List 解析,例如 `api.example.co.uk` 归属 `example.co.uk`。
* URL 使用 ID:列表为 `/websites`,详情为 `/websites/:zoneId`;不使用域名作为 URL 参数。 * URL 使用 ID:列表为 `/websites`,详情为 `/websites/:zoneId`;不使用域名作为 URL 参数。
* Zone 域名必须是明确的 FQDN,禁止录入 `*.example.com`。TLS 证书可仍含通配符 SAN,并用于覆盖明确的 Zone 域名。 * Zone 根域不允许通配符;Zone 域名支持具体 FQDN 或最左侧完整标签为 `*` 的通配符 FQDN,例如 `*.example.com`、`*.s3.example.com`。后端去掉 `*.` 后校验注册根域归属,并拒绝非法 DNS 标签、空白与配置分隔符。
* 一个 Zone 域名至多关联一条反代路由;一条反代路由可关联多个 Zone 域名,因而可跨 Zone 共享同一套上游、缓存、限流、WAF 与 Pages 配置。 * 一个 Zone 域名至多关联一条反代路由;一条反代路由可关联多个 Zone 域名,因而可跨 Zone 共享同一套上游、缓存、限流、WAF 与 Pages 配置。
* Zone 模型本身不新增 DNS 记录、边缘函数、预览子域或租户隔离能力。对外 DNS A 记录的创建/更新由独立的 Cloudflare 指向模块负责,且不改变 Zone / ZoneDomain 表职责。 * Zone 模型本身不新增 DNS 记录、边缘函数、预览子域或租户隔离能力。对外 DNS A 记录的创建/更新由独立的 Cloudflare 指向模块负责,且不改变 Zone / ZoneDomain 表职责。
@@ -43,7 +43,7 @@ erDiagram
### `of_zone_domains` ### `of_zone_domains`
保存 `zone_id`、明确 `domain`、可空的 `proxy_route_id`、可空的 `cert_id` 及时间戳。`domain` 全局唯一;所有关系字段建立索引但不建立物理外键。`proxy_route_id` 允许为空,以承接已准备证书但尚未配置反代的历史域名。 保存 `zone_id`、具体或通配符 `domain`、可空的 `proxy_route_id`、可空的 `cert_id` 及时间戳。`domain` 全局唯一;所有关系字段建立索引但不建立物理外键。`proxy_route_id` 允许为空,以承接已准备证书但尚未配置反代的历史域名。通配符作为一条普通域名记录存储,不展开为子域名列表,不需要额外列或迁移。
`of_proxy_routes` 逐步移除 `domain`、`domains`、`cert_id`、`cert_ids` 与 `domain_cert_ids` 等域名/证书冗余列。路由不得再指定任何 TLS 证书;路由名称 `site_name` 成为稳定的人类可读标识,编译器从关联的 Zone 域名读取 `server_name` 与其 `cert_id`。这使每个明确域名的证书只有一个来源。 `of_proxy_routes` 逐步移除 `domain`、`domains`、`cert_id`、`cert_ids` 与 `domain_cert_ids` 等域名/证书冗余列。路由不得再指定任何 TLS 证书;路由名称 `site_name` 成为稳定的人类可读标识,编译器从关联的 Zone 域名读取 `server_name` 与其 `cert_id`。这使每个明确域名的证书只有一个来源。
@@ -70,13 +70,21 @@ WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚
详情页包含: 详情页包含:
* 概览:域名、路由和有效证书统计;域名—路由—证书摘要;路由级 WAF 与 Pages 摘要。 * 概览:域名、路由和有效证书统计;域名—路由—证书摘要;路由级 WAF 与 Pages 摘要。
* 域名:明确 FQDN 的列表、证书选择和关联路由;不显示或接受通配符域名。 * 域名:具体或通配符 FQDN 的列表、证书选择和关联路由;快捷新增支持 `*` 展开为 `*.当前根域`,多级泛域名须输入完整名称。
* 路由:筛选到当前 Zone 的路由并链接到既有路由详情。 * 路由:筛选到当前 Zone 的路由并链接到既有路由详情。
* 证书:当前 Zone 域名实际引用的证书。 * 证书:当前 Zone 域名实际引用的证书。
* 设置:Zone 备注和受保护的删除操作。 * 设置:Zone 备注和受保护的删除操作。
新增路由时从 Zone 域名中选择;用户也可以先在 Zone 中登记域名,再绑定路由。全局反代路由入口保留,但改用同一套 Zone 域名选择器。 新增路由时从 Zone 域名中选择;用户也可以先在 Zone 中登记域名,再绑定路由。全局反代路由入口保留,但改用同一套 Zone 域名选择器。
### 通配符运行语义
* 配置快照保留 `*.suffix` 原文;绑定路由并发布激活版本后,Agent 使用共享渲染器生成 `server_name *.suffix;`。仅登记域名而未绑定路由不会生成代理规则。
* 路由匹配沿用 OpenResty/Nginx 的规则:具体域名优先于通配符,多个通配符中更长的匹配后缀优先;`*.example.com` 不包含 `example.com` 本身,但可匹配多层子域名。
* TLS 覆盖范围与 HTTP 路由匹配不同:`*.s3.example.com` 站点必须绑定含相应通配符 SAN 的证书;它不能用仅覆盖某个具体子域名的证书替代。单层通配符证书不覆盖根自身或更深层子域名,发布时保留证书覆盖校验。
* Zone 流量统计按实际请求 Host 匹配具体域名或非空通配符后缀。ClickHouse、PostgreSQL 与 SQLite 使用相同的匹配约定,重叠的具体域名/通配符条件不会重复计数;域名数量仍表示登记记录数。
* 通配符不可直接作为访问或探测 URL。Uptime Kuma 同步优先选择路由绑定的具体域名;只有通配符时跳过创建/更新并记录原因,保留已有监控。
## 数据迁移 ## 数据迁移
本次改造分两个发布阶段,以免 SQL 用错误的“末两段域名”规则处理多级公共后缀。操作细则见 [Zone 域名迁移与发布验收](../guide/zone-domain-migration.md)。 本次改造分两个发布阶段,以免 SQL 用错误的“末两段域名”规则处理多级公共后缀。操作细则见 [Zone 域名迁移与发布验收](../guide/zone-domain-migration.md)。
@@ -86,6 +94,8 @@ WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚
3. **代码切换**:控制面 API、配置快照、渲染、前端均以 Zone 域名为唯一来源;路由写入仅使用 `zone_domain_ids`。 3. **代码切换**:控制面 API、配置快照、渲染、前端均以 Zone 域名为唯一来源;路由写入仅使用 `zone_domain_ids`。
4. **第二阶段清理**:Goose SQL `202607130001_drop_legacy_route_domain_columns` 删除 `of_managed_domains` 与 `of_proxy_routes` 冗余列。Down 仅恢复开发库空结构,不回填历史数据。 4. **第二阶段清理**:Goose SQL `202607130001_drop_legacy_route_domain_columns` 删除 `of_managed_domains` 与 `of_proxy_routes` 冗余列。Down 仅恢复开发库空结构,不回填历史数据。
历史数据升级导入时,Unicode 域名经 IDNA 转换并保存为 ASCII(Punycode)形式,以统一根域归属、全局唯一性、HTTP 路由匹配及 HTTPS 证书校验;原记录的证书和站点绑定保持不变。新增域名仍须符合 ASCII DNS 标签规则。
### 运行时模型边界 ### 运行时模型边界
* 持久化:域名与证书只存在于 `of_zone_domains`;`of_proxy_routes` 仅保存路由策略(上游、缓存、限流、WAF 绑定键等)。 * 持久化:域名与证书只存在于 `of_zone_domains`;`of_proxy_routes` 仅保存路由策略(上游、缓存、限流、WAF 绑定键等)。
+12
View File
@@ -12917,6 +12917,7 @@ const docTemplate = `{
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -13044,6 +13045,7 @@ const docTemplate = `{
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -15517,6 +15519,11 @@ const docTemplate = `{
}, },
"target_mode": { "target_mode": {
"type": "string" "type": "string"
},
"ttl": {
"description": "TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。",
"type": "integer",
"example": 1
} }
} }
}, },
@@ -15559,6 +15566,11 @@ const docTemplate = `{
"target_mode": { "target_mode": {
"type": "string" "type": "string"
}, },
"ttl": {
"description": "TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。",
"type": "integer",
"example": 1
},
"updated_at": { "updated_at": {
"type": "string" "type": "string"
} }
+15 -3
View File
@@ -38,16 +38,28 @@
证书和源站就绪后,即可创建核心网站代理路由: 证书和源站就绪后,即可创建核心网站代理路由:
1. 进入左侧导航 **「网站管理」->「域名列表」**,点击 **「新增 Zone」**: 1. 进入左侧导航 **「网站管理」->「域名列表」**,点击 **「新增 Zone」**,登记注册根域(如 `example.com`,不可填写 `*.example.com`)。进入该 Zone 的域名列表添加具体域名或通配符域名:
* **域名**:输入该站点绑定的域名。 * **域名**:输入 `api`、`@`、`*` 或完整名称(如 `*.s3.example.com`)。`*` 展开为 `*.example.com`,不支持 `*.s3` 简写。
* **绑定证书**:选择第一步准备或申请好的证书。 * **绑定证书**:选择第一步准备或申请好的证书。
2. 配置请求路由规则:进入 **「规则管理」** 页面,点击 **「新建规则」** 或编辑已有规则: 2. 配置请求路由规则:进入 **「规则管理」** 页面,点击 **「新建规则」** 或编辑已有规则:
* **规则名称**:输入规则的唯一简易标识(如 `app-portal-route`)。 * **规则名称**:输入规则的唯一简易标识(如 `app-portal-route`)。
* **域名匹配**:填入对应的域名(支持通配符或精确域名,需与上面登记的域名一致)。 * **域名匹配**:从已登记域名中选择具体或通配符域名,也可通过「快捷新增域名」创建并绑定。
* 在下方 **「反向代理」** 选项卡下,选择 **回源方式** 为「直连上游」。 * 在下方 **「反向代理」** 选项卡下,选择 **回源方式** 为「直连上游」。
* **源站选择**:从下拉框中选择第二步创建的源站;或者选择手动输入并填入 `http://10.0.0.20:9000`。 * **源站选择**:从下拉框中选择第二步创建的源站;或者选择手动输入并填入 `http://10.0.0.20:9000`。
3. 点击保存创建配置。 3. 点击保存创建配置。
### 通配符域名示例
要让 `bucket.s3.example.com` 等域名共用站点规则:
1. 在 `example.com` Zone 下添加 `*.s3.example.com` 并绑定路由;不需要逐个登记 bucket 域名。
2. 使用 HTTPS 时绑定 SAN 包含 `*.s3.example.com` 的证书。`*.example.com` 证书不能替代它;`*.s3.example.com` 证书也不覆盖 `s3.example.com` 或 `a.bucket.s3.example.com`。
3. 在 Cloudflare 分组中选择该通配符域名:节点模式跟随节点 IPv4 写 A 记录;自定义模式可写 A、AAAA 或 CNAME。DNS 同步与站点配置发布是两件事,须分别确认成功。
4. 按下一节发布并激活版本,Agent 会收到并渲染 `server_name *.s3.example.com;`。通配符沿用 Nginx 后缀匹配,可匹配多层子域名但不含后缀根自身;具体域名路由优先,重叠通配符以更长后缀优先。TLS 仍受上一条的单层证书覆盖限制。
5. 使用具体主机名访问验证,例如 `https://bucket.s3.example.com`,不要直接打开带 `*` 的地址。Zone 统计会聚合命中的实际 Host;Uptime Kuma 则需要至少绑定一个具体域名,否则跳过该路由的监控创建/更新并记录原因。
不允许 `foo*.example.com`、`a.*.example.com` 或多个 `*`;带协议、路径、端口、域名内部空白及非法 DNS 标签的输入也会被拒绝。
--- ---
## 第四步:发布并生效配置 ## 第四步:发布并生效配置
+12
View File
@@ -12910,6 +12910,7 @@
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -13037,6 +13038,7 @@
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -15510,6 +15512,11 @@
}, },
"target_mode": { "target_mode": {
"type": "string" "type": "string"
},
"ttl": {
"description": "TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。",
"type": "integer",
"example": 1
} }
} }
}, },
@@ -15552,6 +15559,11 @@
"target_mode": { "target_mode": {
"type": "string" "type": "string"
}, },
"ttl": {
"description": "TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。",
"type": "integer",
"example": 1
},
"updated_at": { "updated_at": {
"type": "string" "type": "string"
} }
+10
View File
@@ -338,6 +338,10 @@ definitions:
type: string type: string
target_mode: target_mode:
type: string type: string
ttl:
description: TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。
example: 1
type: integer
type: object type: object
cloudflare.GroupItem: cloudflare.GroupItem:
properties: properties:
@@ -365,6 +369,10 @@ definitions:
type: string type: string
target_mode: target_mode:
type: string type: string
ttl:
description: TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。
example: 1
type: integer
updated_at: updated_at:
type: string type: string
type: object type: object
@@ -12187,6 +12195,7 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。
parameters: parameters:
- description: Zone ID - description: Zone ID
in: path in: path
@@ -12265,6 +12274,7 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。
parameters: parameters:
- description: Zone ID - description: Zone ID
in: path in: path
@@ -12,7 +12,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'; import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
Select, Select,
@@ -64,6 +70,12 @@ export function GroupDialog({
const [backupNodeID, setBackupNodeID] = useState('none'); const [backupNodeID, setBackupNodeID] = useState('none');
const [defaultProxied, setDefaultProxied] = useState(true); const [defaultProxied, setDefaultProxied] = useState(true);
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [ttlMode, setTtlMode] = useState<'auto' | 'manual'>('auto');
const [manualTTL, setManualTTL] = useState('300');
const ttl = ttlMode === 'auto' ? 1 : Number(manualTTL);
const invalidTTL =
ttlMode === 'manual' &&
(!manualTTL.trim() || !Number.isInteger(ttl) || ttl < 60 || ttl > 86400);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -83,6 +95,9 @@ export function GroupDialog({
); );
setDefaultProxied(group?.default_proxied ?? true); setDefaultProxied(group?.default_proxied ?? true);
setEnabled(group?.enabled ?? true); setEnabled(group?.enabled ?? true);
const groupTTL = group ? (group.ttl ?? 300) : 1;
setTtlMode(groupTTL === 1 ? 'auto' : 'manual');
setManualTTL(String(groupTTL === 1 ? 300 : groupTTL));
}, [group, open]); }, [group, open]);
return ( return (
@@ -209,6 +224,54 @@ export function GroupDialog({
</Field> </Field>
</> </>
)} )}
<Field>
<FieldLabel id='cf-ttl-mode-label'>{t('ttl')}</FieldLabel>
<ToggleGroup
type='single'
variant='outline'
value={ttlMode}
aria-labelledby='cf-ttl-mode-label'
aria-describedby='cf-ttl-hint'
className='grid w-full grid-cols-2'
onValueChange={(value) => {
if (value === 'auto' || value === 'manual') {
setTtlMode(value);
}
}}
>
<ToggleGroupItem value='auto' className='w-full'>
{t('ttlAuto')}
</ToggleGroupItem>
<ToggleGroupItem value='manual' className='w-full'>
{t('ttlManual')}
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription id='cf-ttl-hint'>{t('ttlHint')}</FieldDescription>
</Field>
{ttlMode === 'manual' && (
<Field data-invalid={invalidTTL}>
<FieldLabel htmlFor='cf-ttl-seconds'>
{t('ttlSeconds')}
</FieldLabel>
<Input
id='cf-ttl-seconds'
type='number'
min={60}
max={86400}
step={1}
required
value={manualTTL}
aria-invalid={invalidTTL}
aria-describedby={
invalidTTL ? 'cf-ttl-hint cf-ttl-error' : 'cf-ttl-hint'
}
onChange={(event) => setManualTTL(event.target.value)}
/>
{invalidTTL && (
<FieldError id='cf-ttl-error'>{t('ttlError')}</FieldError>
)}
</Field>
)}
<Field orientation='horizontal'> <Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'> <FieldLabel htmlFor='cf-default-proxied'>
{t('defaultProxied')} {t('defaultProxied')}
@@ -235,10 +298,12 @@ export function GroupDialog({
<Button <Button
disabled={ disabled={
pending || pending ||
invalidTTL ||
!name.trim() || !name.trim() ||
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim()) (targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
} }
onClick={() => onClick={() => {
if (invalidTTL) return;
onSubmit({ onSubmit({
name: name.trim(), name: name.trim(),
target_mode: targetMode, target_mode: targetMode,
@@ -251,9 +316,10 @@ export function GroupDialog({
? Number(backupNodeID) ? Number(backupNodeID)
: null, : null,
default_proxied: defaultProxied, default_proxied: defaultProxied,
ttl,
enabled, enabled,
}) });
} }}
> >
{t('save')} {t('save')}
</Button> </Button>
@@ -371,6 +371,11 @@ export function CloudflareGroupDetailPageClient() {
: 'nodeTargetMode', : 'nodeTargetMode',
)} )}
</Badge> </Badge>
<Badge variant='outline'>
{group.ttl === 1
? t('ttlAutoSummary')
: t('ttlSecondsSummary', { seconds: group.ttl ?? 300 })}
</Badge>
</CardContent> </CardContent>
</Card> </Card>
@@ -114,7 +114,7 @@ export function QuickCreateZoneDomainDialog({
const schema = z.object({ const schema = z.object({
zone_id: z.string().min(1, t('selectZone')), zone_id: z.string().min(1, t('selectZone')),
domain_input: z.string().trim().min(1, t('enterDomain')), domain_input: z.string().min(1, t('enterDomain')),
cert_id: z.string(), cert_id: z.string(),
}); });
const form = useForm<Values>({ const form = useForm<Values>({
@@ -3,6 +3,7 @@
* *
* - `@` → apex (zone root itself), e.g. `example.com` * - `@` → apex (zone root itself), e.g. `example.com`
* - single label `name` → `name.example.com` * - single label `name` → `name.example.com`
* - `*` → `*.example.com`; other wildcards require a full FQDN
* - full FQDN must equal the root or end with `.root` * - full FQDN must equal the root or end with `.root`
*/ */
export type ZoneDomainInputError = export type ZoneDomainInputError =
@@ -13,6 +14,15 @@ export type ZoneDomainInputError =
| 'invalidSubdomain' | 'invalidSubdomain'
| 'mustBelongToZone'; | 'mustBelongToZone';
const dnsLabelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
function isDnsName(value: string): boolean {
return (
value.length <= 253 &&
value.split('.').every((label) => dnsLabelPattern.test(label))
);
}
export function resolveZoneDomainInput( export function resolveZoneDomainInput(
rawInput: string, rawInput: string,
zoneRoot: string, zoneRoot: string,
@@ -26,15 +36,7 @@ export function resolveZoneDomainInput(
if (!input) { if (!input) {
return { domain: '', error: 'enterDomain' }; return { domain: '', error: 'enterDomain' };
} }
if (input.includes('*')) { if (/\s/.test(input) || !isDnsName(root)) {
return { domain: '', error: 'noWildcard' };
}
if (
input.includes('://') ||
input.includes('/') ||
input.includes('?') ||
input.includes('#')
) {
return { domain: '', error: 'invalidFormat' }; return { domain: '', error: 'invalidFormat' };
} }
@@ -42,17 +44,28 @@ export function resolveZoneDomainInput(
return { domain: root }; return { domain: root };
} }
// Short label: no dots → prefix under zone root // 星号只能作为最左侧完整标签;*.s3 不推断为 Zone 内的相对名称。
if (!input.includes('.')) { const wildcard = input === '*' || input.startsWith('*.');
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(input)) { if (input.includes('*') && (!wildcard || input.slice(1).includes('*'))) {
return { domain: '', error: 'invalidSubdomain' }; return { domain: '', error: 'noWildcard' };
}
return { domain: `${input}.${root}` };
} }
// Full FQDN let domain = input;
if (input === root || input.endsWith(`.${root}`)) { if (input === '*') {
return { domain: input }; domain = `*.${root}`;
} else if (!input.includes('.')) {
if (!dnsLabelPattern.test(input)) {
return { domain: '', error: 'invalidSubdomain' };
}
domain = `${input}.${root}`;
}
const hostname = wildcard ? domain.slice(2) : domain;
if (domain.length > 253 || !isDnsName(hostname)) {
return { domain: '', error: 'invalidFormat' };
}
if (hostname === root || hostname.endsWith(`.${root}`)) {
return { domain };
} }
return { domain: '', error: 'mustBelongToZone' }; return { domain: '', error: 'mustBelongToZone' };
+4
View File
@@ -1390,6 +1390,8 @@ export interface CloudflareGroup {
primary_node: CloudflareNodeOption; primary_node: CloudflareNodeOption;
backup_node: CloudflareNodeOption | null; backup_node: CloudflareNodeOption | null;
active_node: CloudflareNodeOption; active_node: CloudflareNodeOption;
/** 1 表示自动,手动 TTL 为 60..86400 秒。 */
ttl: number;
default_proxied: boolean; default_proxied: boolean;
enabled: boolean; enabled: boolean;
member_count: number; member_count: number;
@@ -1404,6 +1406,8 @@ export interface CloudflareGroupPayload {
record_content: string; record_content: string;
primary_node_id: number; primary_node_id: number;
backup_node_id: number | null; backup_node_id: number | null;
/** 省略时保留旧调用方行为;1 表示自动,手动为 60..86400 秒。 */
ttl?: number;
default_proxied: boolean; default_proxied: boolean;
enabled: boolean; enabled: boolean;
} }
@@ -1,5 +1,15 @@
{ {
"cloudflare": { "cloudflare": {
"ttlAutoSummary": "DNS-only TTL: Auto (about 300 seconds)",
"ttlSecondsSummary": "DNS-only TTL: {seconds} seconds",
"groupDialog": {
"ttl": "Group TTL",
"ttlAuto": "Auto",
"ttlManual": "Manual",
"ttlSeconds": "TTL (seconds)",
"ttlHint": "Changes apply to all DNS-only (gray-cloud) members in this group; proxied (orange-cloud) members always use Auto. Auto is currently about 300 seconds.",
"ttlError": "Enter a whole number of seconds between 60 and 86400."
},
"move": "Move", "move": "Move",
"batchMove": "Batch Move", "batchMove": "Batch Move",
"batchRemove": "Batch Delete", "batchRemove": "Batch Delete",
@@ -1,5 +1,15 @@
{ {
"cloudflare": { "cloudflare": {
"ttlAutoSummary": "TTL только DNS: авто (около 300 секунд)",
"ttlSecondsSummary": "TTL только DNS: {seconds} сек.",
"groupDialog": {
"ttl": "TTL группы",
"ttlAuto": "Авто",
"ttlManual": "Вручную",
"ttlSeconds": "TTL (секунды)",
"ttlHint": "Изменения применяются ко всем участникам группы в режиме «только DNS» (серое облако); для проксируемых записей (оранжевое облако) TTL всегда автоматический. Сейчас Auto — около 300 секунд.",
"ttlError": "Введите целое число секунд от 60 до 86400."
},
"move": "Переместить", "move": "Переместить",
"batchMove": "Пакетное перемещение", "batchMove": "Пакетное перемещение",
"batchRemove": "Пакетное удаление", "batchRemove": "Пакетное удаление",
@@ -1,5 +1,15 @@
{ {
"cloudflare": { "cloudflare": {
"ttlAutoSummary": "灰云 TTL:自动(约 300 秒)",
"ttlSecondsSummary": "灰云 TTL:{seconds} 秒",
"groupDialog": {
"ttl": "分组 TTL",
"ttlAuto": "自动",
"ttlManual": "手动",
"ttlSeconds": "TTL(秒)",
"ttlHint": "修改将应用于分组内所有灰云(仅 DNS)成员;橙云始终自动。Auto 当前约为 300 秒。",
"ttlError": "请输入 60 至 86400 之间的整数秒数。"
},
"move": "移动", "move": "移动",
"batchMove": "批量移动", "batchMove": "批量移动",
"batchRemove": "批量删除", "batchRemove": "批量删除",
+5 -5
View File
@@ -79,21 +79,21 @@
"expiresAt": "Expires", "expiresAt": "Expires",
"selectZone": "Select a Zone first", "selectZone": "Select a Zone first",
"enterDomain": "Enter a domain", "enterDomain": "Enter a domain",
"noWildcard": "Zone domains do not support wildcards", "noWildcard": "A wildcard must be a single * in the leftmost label, such as *.example.com",
"invalidFormat": "Invalid domain format", "invalidFormat": "Invalid domain format",
"invalidSubdomain": "Invalid subdomain name format", "invalidSubdomain": "Invalid subdomain name format",
"mustBelongToZone": "The domain must belong to Zone {root}", "mustBelongToZone": "The domain must belong to Zone {root}",
"domainAdded": "Domain added", "domainAdded": "Domain added",
"addFailed": "Add failed", "addFailed": "Add failed",
"quickCreateTitle": "Quick add domain", "quickCreateTitle": "Quick add domain",
"quickCreateDesc": "After selecting a Zone, enter a subdomain name (such as api), @ (the root), or a full FQDN.", "quickCreateDesc": "Enter a subdomain (api), @ (the root), * (wildcard under the root), or a full FQDN. For nested wildcards, enter the full name, such as *.s3.example.com; *.s3 shorthand is not supported.",
"selectRegisteredRoot": "Select registered root domain", "selectRegisteredRoot": "Select registered root domain",
"zoneLabel": "Zone:", "zoneLabel": "Zone:",
"domain": "Domain", "domain": "Domain",
"domainPlaceholderWithZone": "api or @ or api.{domain}", "domainPlaceholderWithZone": "api / @ / * / *.s3.{domain}",
"domainPlaceholder": "api / @ / full domain", "domainPlaceholder": "api / @ / * / full domain",
"willCreate": "Will create:", "willCreate": "Will create:",
"domainExample": "Example: enter <api>api</api> → <fqdn>api.zone.com</fqdn>; <apex>@</apex> → the root itself", "domainExample": "Example: <api>api</api> → <fqdn>api.zone.com</fqdn>; <apex>@</apex> → the root; * → *.zone.com. A wildcard cannot be opened directly; visit a concrete matching hostname.",
"certOptional": "Certificate (optional)", "certOptional": "Certificate (optional)",
"noCert": "No certificate" "noCert": "No certificate"
}, },
+1 -1
View File
@@ -79,7 +79,7 @@
"expiresAt": "Срок действия истекает", "expiresAt": "Срок действия истекает",
"selectZone": "Сначала выберите зону", "selectZone": "Сначала выберите зону",
"enterDomain": "Введите домен", "enterDomain": "Введите домен",
"noWildcard": "Домены зон не поддерживают подстановочные знаки", "noWildcard": "Допустим только один символ * как крайняя левая метка, например *.example.com",
"invalidFormat": "Неверный формат домена", "invalidFormat": "Неверный формат домена",
"invalidSubdomain": "Неверный формат имени субдомена", "invalidSubdomain": "Неверный формат имени субдомена",
"mustBelongToZone": "Домен должен принадлежать зоне {root}", "mustBelongToZone": "Домен должен принадлежать зоне {root}",
@@ -79,21 +79,21 @@
"expiresAt": "到期时间", "expiresAt": "到期时间",
"selectZone": "请先选择 Zone", "selectZone": "请先选择 Zone",
"enterDomain": "请输入域名", "enterDomain": "请输入域名",
"noWildcard": "Zone 域名不支持通配符", "noWildcard": "通配符只能是最左侧的完整标签 *,例如 *.example.com,且只能出现一次",
"invalidFormat": "域名格式不合法", "invalidFormat": "域名格式不合法",
"invalidSubdomain": "子域名称格式不合法", "invalidSubdomain": "子域名称格式不合法",
"mustBelongToZone": "域名必须属于 Zone {root}", "mustBelongToZone": "域名必须属于 Zone {root}",
"domainAdded": "域名已添加", "domainAdded": "域名已添加",
"addFailed": "添加失败", "addFailed": "添加失败",
"quickCreateTitle": "快捷新增域名", "quickCreateTitle": "快捷新增域名",
"quickCreateDesc": "选择 Zone 后输入:子域名称(如 api)、@(根域)或完整 FQDN。", "quickCreateDesc": "输入子域名称(api)、@(根域)、*(根域下泛域名)或完整 FQDN。多级泛域名请填写完整名称,例如 *.s3.example.com,不支持 *.s3 简写。",
"selectRegisteredRoot": "选择注册根域", "selectRegisteredRoot": "选择注册根域",
"zoneLabel": "Zone:", "zoneLabel": "Zone:",
"domain": "域名", "domain": "域名",
"domainPlaceholderWithZone": "api 或 @ 或 api.{domain}", "domainPlaceholderWithZone": "api / @ / * / *.s3.{domain}",
"domainPlaceholder": "api / @ / 完整域名", "domainPlaceholder": "api / @ / * / 完整域名",
"willCreate": "将创建:", "willCreate": "将创建:",
"domainExample": "示例:输入 <api>api</api> → <fqdn>api.zone.com</fqdn>;<apex>@</apex> → 根域本身", "domainExample": "示例:<api>api</api> → <fqdn>api.zone.com</fqdn>;<apex>@</apex> → 根域;* → *.zone.com。泛域名不能直接打开,请使用匹配的具体主机名访问。",
"certOptional": "证书(可选)", "certOptional": "证书(可选)",
"noCert": "不绑定证书" "noCert": "不绑定证书"
}, },
@@ -63,6 +63,7 @@ const mockGroup = {
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
backup_node: null, backup_node: null,
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
ttl: 300,
default_proxied: true, default_proxied: true,
enabled: true, enabled: true,
member_count: 2, member_count: 2,
@@ -79,6 +80,7 @@ const mockTargetGroup = {
primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' }, primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
backup_node: null, backup_node: null,
active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' }, active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
ttl: 1,
default_proxied: false, default_proxied: false,
enabled: true, enabled: true,
member_count: 0, member_count: 0,
@@ -171,6 +173,21 @@ describe('Cloudflare group detail refresh', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it.each([
{ ttl: 1, label: '灰云 TTL:自动(约 300 秒)' },
{ ttl: 600, label: '灰云 TTL:600 秒' },
])(
'shows the group TTL $ttl in the detail summary',
async ({ ttl, label }) => {
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
group: { ...mockGroup, ttl },
members: [],
});
renderPage();
expect(await screen.findByText(label)).toBeVisible();
},
);
it('refreshes detail data when the refresh button is clicked', async () => { it('refreshes detail data when the refresh button is clicked', async () => {
renderPage(); renderPage();
@@ -115,6 +115,7 @@ describe('Cloudflare overview', () => {
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
backup_node: null, backup_node: null,
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' }, active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
ttl: 300,
default_proxied: true, default_proxied: true,
enabled: true, enabled: true,
member_count: 2, member_count: 2,
@@ -0,0 +1,215 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ComponentProps } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { GroupDialog } from '@/app/(main)/cloudflare/components/group-dialog';
import type { CloudflareGroup } from '@/lib/services/openflare';
import zhCN from '@/messages/zh-CN.json';
const node = { id: 1, name: '主节点', ip: '192.0.2.1' };
const group: CloudflareGroup = {
id: 7,
name: '生产节点',
target_mode: 'node',
record_type: 'A',
record_content: '',
primary_node: node,
backup_node: null,
active_node: node,
ttl: 900,
default_proxied: true,
enabled: true,
member_count: 2,
created_at: '',
updated_at: '',
};
function renderDialog(
overrides: Partial<ComponentProps<typeof GroupDialog>> = {},
) {
const props: ComponentProps<typeof GroupDialog> = {
open: true,
onOpenChange: vi.fn(),
nodes: [],
pending: false,
onSubmit: vi.fn(),
...overrides,
};
const view = (nextProps: ComponentProps<typeof GroupDialog>) => (
<NextIntlClientProvider
locale='zh-CN'
messages={zhCN}
timeZone='Asia/Shanghai'
>
<GroupDialog {...nextProps} />
</NextIntlClientProvider>
);
const result = render(view(props));
return {
...props,
rerenderDialog: (nextProps: Partial<ComponentProps<typeof GroupDialog>>) =>
result.rerender(view({ ...props, ...nextProps })),
};
}
function fillCustomTarget() {
fireEvent.change(screen.getByLabelText('分组名称'), {
target: { value: '自定义分组' },
});
fireEvent.click(screen.getByRole('radio', { name: '自定义' }));
fireEvent.change(screen.getByLabelText('记录值'), {
target: { value: '192.0.2.10' },
});
}
function manualInput() {
return screen.getByRole('spinbutton', { name: 'TTL(秒)' });
}
function save() {
fireEvent.click(screen.getByRole('button', { name: '保存' }));
}
describe('Cloudflare group TTL', () => {
it('defaults new groups to Auto and sends ttl=1', () => {
const { onSubmit } = renderDialog();
expect(screen.getByRole('radiogroup', { name: '分组 TTL' })).toBeVisible();
expect(screen.getByRole('radio', { name: '自动' })).toBeChecked();
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
expect(screen.getByText(/Auto 当前约为 300 秒/)).toBeVisible();
fillCustomTarget();
save();
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ target_mode: 'custom', ttl: 1 }),
);
});
it.each(['node', 'custom'] as const)(
'saves a manual TTL for a %s target even when default_proxied is enabled',
(targetMode) => {
const { onSubmit } = renderDialog({
group: {
...group,
target_mode: targetMode,
record_content: targetMode === 'custom' ? '192.0.2.10' : '',
},
});
expect(
screen.getByRole('switch', { name: '新成员默认开启橙云' }),
).toBeChecked();
expect(screen.getByRole('radio', { name: '手动' })).toBeChecked();
expect(manualInput()).toHaveValue(900);
expect(manualInput()).toBeEnabled();
fireEvent.change(manualInput(), { target: { value: '600' } });
save();
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
target_mode: targetMode,
ttl: 600,
default_proxied: true,
}),
);
},
);
it('allows a new custom group to save a manual TTL', () => {
const { onSubmit } = renderDialog();
fillCustomTarget();
fireEvent.click(screen.getByRole('radio', { name: '手动' }));
fireEvent.change(manualInput(), { target: { value: '1200' } });
save();
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ target_mode: 'custom', ttl: 1200 }),
);
});
it('reflects Auto on edit and does not allow deselecting it', () => {
const { onSubmit } = renderDialog({ group: { ...group, ttl: 1 } });
fireEvent.click(screen.getByRole('radio', { name: '自动' }));
expect(screen.getByRole('radio', { name: '自动' })).toBeChecked();
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
save();
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ ttl: 1 }));
});
it.each(['', '60.5', '59', '86401', '0', '-1'])(
'rejects invalid manual TTL %j with an accessible error',
(value) => {
const { onSubmit } = renderDialog({ group });
fireEvent.change(manualInput(), { target: { value } });
expect(manualInput()).toHaveAttribute('aria-invalid', 'true');
expect(manualInput()).toHaveAccessibleDescription(
/请输入 60 至 86400 之间的整数秒数/,
);
expect(screen.getByRole('alert')).toHaveTextContent(
'请输入 60 至 86400 之间的整数秒数。',
);
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
save();
expect(onSubmit).not.toHaveBeenCalled();
},
);
it.each([60, 86400])(
'accepts the inclusive manual TTL boundary %i',
(ttl) => {
const { onSubmit } = renderDialog({ group });
fireEvent.change(manualInput(), { target: { value: String(ttl) } });
expect(manualInput()).toHaveAttribute('aria-invalid', 'false');
save();
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ ttl }));
},
);
it('switches invalid manual input to Auto and sends 1 without stale errors', () => {
const { onSubmit } = renderDialog({ group });
fireEvent.change(manualInput(), { target: { value: '' } });
expect(screen.getByRole('alert')).toBeVisible();
fireEvent.click(screen.getByRole('radio', { name: '自动' }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
save();
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ ttl: 1 }));
});
it('resets a new group to Auto after closing and reopening', () => {
const { rerenderDialog, onOpenChange } = renderDialog();
fireEvent.click(screen.getByRole('radio', { name: '手动' }));
fireEvent.change(manualInput(), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
rerenderDialog({ open: false });
rerenderDialog({ open: true });
expect(screen.getByRole('radio', { name: '自动' })).toBeChecked();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('radio', { name: '手动' }));
expect(manualInput()).toHaveValue(300);
});
it('restores saved TTL after reopening and reflects a different group', () => {
const { rerenderDialog } = renderDialog({ group });
fireEvent.change(manualInput(), { target: { value: '600' } });
rerenderDialog({ open: false });
rerenderDialog({ open: true });
expect(manualInput()).toHaveValue(900);
rerenderDialog({ group: { ...group, id: 8, ttl: 1800 } });
expect(manualInput()).toHaveValue(1800);
rerenderDialog({ group: { ...group, id: 9, ttl: 1 } });
expect(screen.getByRole('radio', { name: '自动' })).toBeChecked();
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
});
it('uses 300 seconds for legacy responses without ttl', () => {
const { ttl: _ttl, ...legacyGroup } = group;
const { onSubmit } = renderDialog({
group: legacyGroup as CloudflareGroup,
});
expect(manualInput()).toHaveValue(300);
save();
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ ttl: 300 }),
);
});
});
@@ -150,4 +150,38 @@ describe('MemberAddDialog advanced domain picker', () => {
fireEvent.click(screen.getByRole('button', { name: '添加并同步' })); fireEvent.click(screen.getByRole('button', { name: '添加并同步' }));
expect(onSubmit).toHaveBeenCalledWith([2], false); expect(onSubmit).toHaveBeenCalledWith([2], false);
}); });
it('searches and selects a wildcard domain without treating it as a URL', () => {
const onSubmit = vi.fn();
render(
<NextIntlClientProvider
locale='zh-CN'
messages={zhCN}
timeZone='Asia/Shanghai'
>
<MemberAddDialog
open
onOpenChange={vi.fn()}
domains={[
...domains,
{ ...domains[0], id: 5, domain: '*.s3.example.com' },
]}
defaultProxied={false}
pending={false}
onSubmit={onSubmit}
/>
</NextIntlClientProvider>,
);
fireEvent.change(screen.getByPlaceholderText('搜索域名或顶级域…'), {
target: { value: '*.s3' },
});
expect(screen.queryByText('www.example.com')).not.toBeInTheDocument();
const label = screen.getByText('*.s3.example.com').closest('label');
expect(label).toBeTruthy();
fireEvent.click(within(label as HTMLElement).getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: '添加并同步' }));
expect(onSubmit).toHaveBeenCalledWith([5], false);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
}); });
@@ -1,34 +1,118 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveZoneDomainInput } from '@/app/(main)/websites/components/resolve-zone-domain-input'; import {
previewZoneDomainInput,
resolveZoneDomainInput,
} from '@/app/(main)/websites/components/resolve-zone-domain-input';
const root = 'example.com';
describe('resolveZoneDomainInput', () => { describe('resolveZoneDomainInput', () => {
it('maps short label and apex @ under zone root', () => { it.each([
expect(resolveZoneDomainInput('api', 'example.com')).toEqual({ ['api', 'api.example.com'],
domain: 'api.example.com', ['@', root],
}); [root, root],
expect(resolveZoneDomainInput('@', 'example.com')).toEqual({ ['www.api.example.com', 'www.api.example.com'],
domain: 'example.com', ['API.Example.COM', 'api.example.com'],
['*', '*.example.com'],
['*.example.com', '*.example.com'],
['*.s3.example.com', '*.s3.example.com'],
['*.S3.Example.COM', '*.s3.example.com'],
[' *.Example.COM ', '*.example.com'],
[' api ', 'api.example.com'],
['*.example.com\n', '*.example.com'],
['\t*.example.com', '*.example.com'],
[`${'a'.repeat(63)}.example.com`, `${'a'.repeat(63)}.example.com`],
])('resolves %s to %s', (input, domain) => {
expect(resolveZoneDomainInput(input, root)).toEqual({ domain });
expect(previewZoneDomainInput(input, root)).toBe(domain);
});
it.each([
'evil.com',
'*.evil.com',
'*.notexample.com',
'*.example.com.evil.com',
'*.s3',
'api.s3',
])(
'rejects %s outside the zone without expanding dotted shorthand',
(input) => {
expect(resolveZoneDomainInput(input, root)).toEqual({
domain: '',
error: 'mustBelongToZone',
});
},
);
it.each([
'foo*',
'foo*.example.com',
'a.*',
'a.*.example.com',
'**',
'**.example.com',
'*.*.example.com',
'*.s*.example.com',
])('rejects wildcard outside the single leftmost label: %s', (input) => {
expect(resolveZoneDomainInput(input, root)).toEqual({
domain: '',
error: 'noWildcard',
}); });
}); });
it('accepts full FQDN under the zone', () => { it.each([
expect( '-api',
resolveZoneDomainInput('www.api.example.com', 'example.com'), 'api-',
).toEqual({ '_api',
domain: 'www.api.example.com', '-api.example.com',
}); 'api-.example.com',
expect(resolveZoneDomainInput('example.com', 'example.com')).toEqual({ '_api.example.com',
domain: 'example.com', '*.bad_.example.com',
}); '*.-bad.example.com',
'*.bad-.example.com',
'..example.com',
'*.example..com',
'*.example.com.',
'*.example.com:443',
'*.example.com;other',
'*.example.com/path',
'*.example.com?query',
'*.example.com#fragment',
'https://*.example.com',
'https://api.example.com',
'user@api.example.com',
'api.example.com:80',
'api.example.com;other',
'a b.example.com',
'*.s3 .example.com',
'*.exa\nmple.com',
'*.s3\t.example.com',
`${'a'.repeat(64)}.example.com`,
`*.${'a'.repeat(64)}.example.com`,
`${`${'a'.repeat(63)}.`.repeat(4)}example.com`,
])('rejects malformed DNS names and unsafe input: %s', (input) => {
expect(resolveZoneDomainInput(input, root).error).toBeTruthy();
expect(previewZoneDomainInput(input, root)).toBe('');
}); });
it('rejects foreign domains and wildcards', () => { it('checks total length after expanding shorthand', () => {
expect( const longRoot = `${'a'.repeat(63)}.${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(61)}`;
resolveZoneDomainInput('evil.com', 'example.com').error, expect(longRoot).toHaveLength(253);
).toBeTruthy(); expect(resolveZoneDomainInput('@', longRoot)).toEqual({ domain: longRoot });
expect( expect(resolveZoneDomainInput('*', longRoot).error).toBe('invalidFormat');
resolveZoneDomainInput('*.example.com', 'example.com').error, expect(resolveZoneDomainInput('api', longRoot).error).toBe('invalidFormat');
).toBeTruthy(); });
it('requires input and a selected, non-wildcard zone root', () => {
expect(resolveZoneDomainInput('*', '').error).toBe('selectZone');
expect(resolveZoneDomainInput('', root).error).toBe('enterDomain');
expect(resolveZoneDomainInput(' ', root).error).toBe('enterDomain');
expect(resolveZoneDomainInput('*', '*.example.com').error).toBe(
'invalidFormat',
);
expect(resolveZoneDomainInput('*', 'Example.COM')).toEqual({
domain: '*.example.com',
});
}); });
}); });
@@ -0,0 +1,175 @@
import type { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QuickCreateZoneDomainDialog } from '@/app/(main)/websites/components/quick-create-zone-domain-dialog';
import { ZoneDomainDialog } from '@/app/(main)/websites/[zoneId]/components/zone-domain-dialog';
import { ZoneDomainsTable } from '@/app/(main)/websites/[zoneId]/components/zone-domains-table';
import { ZoneEditorDialog } from '@/app/(main)/websites/[zoneId]/components/zone-editor-dialog';
import {
TlsCertificateService,
ZoneDomainService,
ZoneService,
type ZoneDomainItem,
} from '@/lib/services/openflare';
import zhCN from '@/messages/zh-CN.json';
vi.mock('@/lib/services/openflare', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/lib/services/openflare')>();
return {
...actual,
TlsCertificateService: { list: vi.fn() },
ZoneDomainService: { create: vi.fn() },
ZoneService: { create: vi.fn(), update: vi.fn() },
};
});
const zone = { id: 1, domain: 'example.com', created_at: '', updated_at: '' };
const wildcardDomain: ZoneDomainItem = {
id: 7,
zone_id: 1,
domain: '*.example.com',
cert_id: null,
proxy_route_id: null,
created_at: '',
updated_at: '',
};
function renderUI(ui: ReactElement) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(
<NextIntlClientProvider
locale='zh-CN'
messages={zhCN}
timeZone='Asia/Shanghai'
>
<QueryClientProvider client={client}>{ui}</QueryClientProvider>
</NextIntlClientProvider>,
);
}
describe('wildcard domain entry points', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(TlsCertificateService.list).mockResolvedValue([]);
vi.mocked(ZoneDomainService.create).mockImplementation(
async (_zoneId, payload) => ({
...wildcardDomain,
domain: payload.domain,
}),
);
});
it('quick-add previews and submits * as a wildcard under the selected zone', async () => {
const onCreated = vi.fn();
renderUI(
<QuickCreateZoneDomainDialog
open
onOpenChange={vi.fn()}
zones={[zone]}
fixedZoneId={zone.id}
onCreated={onCreated}
/>,
);
fireEvent.change(screen.getByLabelText('域名'), {
target: { value: '*' },
});
expect(screen.getByText('*.example.com')).toBeVisible();
fireEvent.submit(document.getElementById('quick-create-zone-domain')!);
await waitFor(() => {
expect(ZoneDomainService.create).toHaveBeenCalledWith(1, {
domain: '*.example.com',
cert_id: null,
});
expect(onCreated).toHaveBeenCalledWith(wildcardDomain);
});
});
it('the Zone details dialog uses the same nested wildcard resolver', async () => {
renderUI(
<ZoneDomainDialog
open
onOpenChange={vi.fn()}
zoneId={zone.id}
zoneRoot={zone.domain}
onSaved={vi.fn()}
/>,
);
fireEvent.change(screen.getByLabelText('域名'), {
target: { value: '*.S3.Example.COM' },
});
expect(screen.getByText('*.s3.example.com')).toBeVisible();
fireEvent.submit(document.getElementById('quick-create-zone-domain')!);
await waitFor(() => {
expect(ZoneDomainService.create).toHaveBeenCalledWith(1, {
domain: '*.s3.example.com',
cert_id: null,
});
});
});
it.each(['*.s3', '*.other.com', 'a.*.example.com', '*.exa mple.com'])(
'blocks invalid quick-add input without calling the API: %s',
async (input) => {
renderUI(
<ZoneDomainDialog
open
onOpenChange={vi.fn()}
zoneId={zone.id}
zoneRoot={zone.domain}
onSaved={vi.fn()}
/>,
);
fireEvent.change(screen.getByLabelText('域名'), {
target: { value: input },
});
fireEvent.submit(document.getElementById('quick-create-zone-domain')!);
await waitFor(() => {
expect(document.querySelector('.text-destructive')).toBeInTheDocument();
});
expect(ZoneDomainService.create).not.toHaveBeenCalled();
},
);
it.each([false, true])(
'root domain still rejects wildcards (edit=%s)',
async (edit) => {
renderUI(
<ZoneEditorDialog
open
onOpenChange={vi.fn()}
zone={edit ? zone : undefined}
/>,
);
fireEvent.change(screen.getByLabelText('根域'), {
target: { value: '*.example.com' },
});
fireEvent.submit(document.getElementById('zone-editor')!);
expect(
await screen.findByText('请输入不含协议或通配符的根域'),
).toBeVisible();
expect(ZoneService.create).not.toHaveBeenCalled();
expect(ZoneService.update).not.toHaveBeenCalled();
},
);
it('lists wildcard domains as text, never as a clickable wildcard URL', () => {
renderUI(
<ZoneDomainsTable
zoneId={zone.id}
zoneRoot={zone.domain}
domains={[wildcardDomain]}
certificates={[]}
routes={[]}
onChanged={vi.fn()}
/>,
);
expect(screen.getByText('*.example.com')).toBeVisible();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
@@ -110,4 +110,28 @@ describe('ZoneDomainSelector', () => {
expect(screen.getByText('bound.example.com')).toBeVisible(); expect(screen.getByText('bound.example.com')).toBeVisible();
expect(screen.getByText('api.example.com')).toBeVisible(); expect(screen.getByText('api.example.com')).toBeVisible();
}); });
it('searches and selects wildcard domains by ID without generating wildcard URLs', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderSelector(
<ZoneDomainSelector
value={[]}
onChange={onChange}
domains={[
...domains,
{ ...domains[0], id: 9, domain: '*.s3.example.com' },
]}
zones={zones}
/>,
);
await user.type(screen.getByRole('textbox'), '*.s3');
expect(screen.queryByText('api.example.com')).not.toBeInTheDocument();
await user.click(screen.getByText('*.s3.example.com'));
expect(onChange).toHaveBeenCalledWith([9]);
for (const link of screen.getAllByRole('link')) {
expect(link.getAttribute('href')).not.toContain('*');
}
});
}); });
+3 -2
View File
@@ -48,8 +48,10 @@ require (
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
go.opentelemetry.io/otel v1.45.0 go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0
go.opentelemetry.io/otel/sdk v1.45.0 go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/trace v1.45.0 go.opentelemetry.io/otel/trace v1.45.0
go.opentelemetry.io/proto/otlp v1.11.0
go.uber.org/zap v1.28.0 go.uber.org/zap v1.28.0
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.55.0
golang.org/x/image v0.44.0 golang.org/x/image v0.44.0
@@ -57,6 +59,7 @@ require (
golang.org/x/net v0.58.0 golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0 golang.org/x/sync v0.22.0
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/postgres v1.6.2 gorm.io/driver/postgres v1.6.2
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
@@ -170,7 +173,6 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/otel/log v0.6.0 // indirect go.opentelemetry.io/otel/log v0.6.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
@@ -183,7 +185,6 @@ require (
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // 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/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.2 // indirect google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.6.0 // indirect gorm.io/driver/mysql v1.6.0 // indirect
+2
View File
@@ -356,6 +356,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo=
go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8= go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8=
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package sync
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"math/big"
"path/filepath"
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
"github.com/Rain-kl/Wavelet/internal/apps/agent/state"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
)
func TestSyncWildcardConfig(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
cert := &x509.Certificate{SerialNumber: big.NewInt(1), DNSNames: []string{"*.s3.example.com"}}
der, err := x509.CreateCertificate(rand.Reader, cert, cert, publicKey, privateKey)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
t.Fatal(err)
}
files := []protocol.SupportFile{
{Path: "1.crt", Content: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))},
{Path: "1.key", Content: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}))},
}
for _, https := range []bool{false, true} {
t.Run(map[bool]string{false: "HTTP", true: "HTTPS"}[https], func(t *testing.T) {
doc := openrestyrender.Document{Routes: []openrestyrender.Route{{
ID: 1, SiteName: "wildcard", Domains: []string{"*.s3.example.com"},
OriginURL: "http://127.0.0.1:8080", EnableHTTPS: https, RedirectHTTP: https,
DomainCertIDs: []uint{1}, Enabled: true,
}}}
source, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
client := &fakeClient{config: protocol.ActiveConfigResponse{
Version: "wildcard-v1", Checksum: "wildcard-checksum", SourceConfigJSON: string(source), SupportFiles: files,
}}
dir := t.TempDir()
store := state.NewStore(filepath.Join(dir, "state.json"))
if _, err := store.EnsureNodeID(); err != nil {
t.Fatal(err)
}
manager := &fakeManager{}
service := New(client, manager, store)
if err := service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: client.config.Version, Checksum: client.config.Checksum}); err != nil {
t.Fatalf("SyncOnce(wildcard, HTTPS=%t) error = %v", https, err)
}
if len(manager.applyRouteContents) != 1 {
t.Fatalf("agent apply calls = %d, want 1", len(manager.applyRouteContents))
}
content := manager.applyRouteContents[0]
if !strings.Contains(content, "server_name *.s3.example.com;") {
t.Errorf("agent apply config missing wildcard server_name: %s", content)
}
if https && !strings.Contains(content, "listen 443 ssl;") {
t.Error("agent apply config missing wildcard HTTPS server")
}
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultSuccess {
t.Errorf("SyncOnce(wildcard) apply reports = %+v, want success", client.reports)
}
snapshot, err := store.Load()
if err != nil || snapshot.CurrentVersion != client.config.Version {
t.Errorf("agent applied version = %+v, error=%v, want %s", snapshot, err, client.config.Version)
}
})
}
}
@@ -14,6 +14,7 @@ const (
errGroupTargetModeInvalid = "select a valid target mode" errGroupTargetModeInvalid = "select a valid target mode"
errRecordTypeInvalid = "select a CNAME, A or AAAA record type" errRecordTypeInvalid = "select a CNAME, A or AAAA record type"
errRecordContentInvalid = "invalid DNS record value; check the domain name or IP address" errRecordContentInvalid = "invalid DNS record value; check the domain name or IP address"
errGroupTTLInvalid = "TTL must be 1 (automatic) or an integer between 60 and 86400 seconds"
errNodeInvalid = "select a valid edge node" errNodeInvalid = "select a valid edge node"
errNodeIPv4Required = "the active node requires a valid IPv4 address" errNodeIPv4Required = "the active node requires a valid IPv4 address"
errGroupDisabled = "target group is disabled" errGroupDisabled = "target group is disabled"
@@ -19,6 +19,13 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const (
automaticTTL = 1
legacyDefaultTTL = 300
minManualTTL = 60
maxManualTTL = 86400
)
var clientFactory = func(token string) Client { return NewHTTPClient(token) } var clientFactory = func(token string) Client { return NewHTTPClient(token) }
// SetClientFactoryForTest replaces Cloudflare client construction for tests. // SetClientFactoryForTest replaces Cloudflare client construction for tests.
@@ -227,6 +234,16 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input
if targetMode != model.CFPointingTargetModeNode && targetMode != model.CFPointingTargetModeCustom { if targetMode != model.CFPointingTargetModeNode && targetMode != model.CFPointingTargetModeCustom {
return nil, errors.New(errGroupTargetModeInvalid) return nil, errors.New(errGroupTargetModeInvalid)
} }
ttl := automaticTTL
if existing != nil {
ttl = groupTTL(existing)
}
if input.TTL != nil {
ttl = *input.TTL
}
if ttl != automaticTTL && (ttl < minManualTTL || ttl > maxManualTTL) {
return nil, errors.New(errGroupTTLInvalid)
}
primaryNodeID := uint(0) primaryNodeID := uint(0)
var backupNodeID *uint var backupNodeID *uint
recordType := strings.ToUpper(strings.TrimSpace(input.RecordType)) recordType := strings.ToUpper(strings.TrimSpace(input.RecordType))
@@ -259,6 +276,7 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input
existing.TargetMode = targetMode existing.TargetMode = targetMode
existing.RecordType = recordType existing.RecordType = recordType
existing.RecordContent = recordContent existing.RecordContent = recordContent
existing.TTL = ttl
existing.PrimaryNodeID = primaryNodeID existing.PrimaryNodeID = primaryNodeID
existing.ActiveNodeID = activeNodeID existing.ActiveNodeID = activeNodeID
existing.BackupNodeID = backupNodeID existing.BackupNodeID = backupNodeID
@@ -267,6 +285,14 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input
return existing, nil return existing, nil
} }
// groupTTL 保留升级前分组的 300 秒行为;数据库零值不直接发送给 Cloudflare。
func groupTTL(group *model.CFPointingGroup) int {
if group.TTL == 0 {
return legacyDefaultTTL
}
return group.TTL
}
func equalOptionalUint(left, right *uint) bool { func equalOptionalUint(left, right *uint) bool {
if left == nil || right == nil { if left == nil || right == nil {
return left == right return left == right
@@ -419,6 +445,7 @@ func buildGroupItem(ctx context.Context, group *model.CFPointingGroup) (*GroupIt
return nil, err 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, 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.TTL = groupTTL(group)
if group.BackupNodeID != nil { if group.BackupNodeID != nil {
backup, backupErr := lookupGroupNode(ctx, group.ID, *group.BackupNodeID) backup, backupErr := lookupGroupNode(ctx, group.ID, *group.BackupNodeID)
if backupErr != nil { if backupErr != nil {
@@ -76,9 +76,9 @@ func reconcileMember(ctx context.Context, memberID uint) error {
} }
input.Name = state.Domain.Domain input.Name = state.Domain.Domain
input.Proxied = state.Member.Proxied input.Proxied = state.Member.Proxied
input.TTL = 300 input.TTL = groupTTL(&state.Group)
if input.Proxied { if input.Proxied {
input.TTL = 1 input.TTL = automaticTTL
} }
recordID := state.Member.CFRecordID recordID := state.Member.CFRecordID
if recordID != "" { if recordID != "" {
@@ -0,0 +1,255 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cloudflare
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"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"
)
func TestGroupFromInputTTL(t *testing.T) {
auto, manual, minimum, maximum := 1, 120, 60, 86400
tests := []struct {
name string
existing *model.CFPointingGroup
ttl *int
want int
}{
{name: "new_default_auto", want: 1},
{name: "explicit_auto", ttl: &auto, want: 1},
{name: "manual", ttl: &manual, want: 120},
{name: "minimum", ttl: &minimum, want: 60},
{name: "maximum", ttl: &maximum, want: 86400},
{name: "legacy_default", existing: &model.CFPointingGroup{}, want: 300},
{name: "preserve_manual", existing: &model.CFPointingGroup{TTL: 600}, want: 600},
{name: "preserve_auto", existing: &model.CFPointingGroup{TTL: 1}, want: 1},
{name: "manual_to_auto", existing: &model.CFPointingGroup{TTL: 600}, ttl: &auto, want: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
group, err := groupFromInput(context.Background(), tt.existing, GroupInput{
Name: "custom", TargetMode: "custom", RecordType: "CNAME", RecordContent: "origin.example.com", TTL: tt.ttl,
})
if err != nil {
t.Fatalf("groupFromInput(%s) error = %v", tt.name, err)
}
if group.TTL != tt.want {
t.Errorf("groupFromInput(%s).TTL = %d, want %d", tt.name, group.TTL, tt.want)
}
})
}
}
func TestCreateGroupHandlerTTL(t *testing.T) {
tests := []struct {
name string
ttlJSON string
wantStatus int
wantTTL int
}{
{name: "omitted", wantStatus: http.StatusOK, wantTTL: 1},
{name: "auto", ttlJSON: `,"ttl":1`, wantStatus: http.StatusOK, wantTTL: 1},
{name: "manual", ttlJSON: `,"ttl":120`, wantStatus: http.StatusOK, wantTTL: 120},
{name: "zero", ttlJSON: `,"ttl":0`, wantStatus: http.StatusBadRequest},
{name: "negative", ttlJSON: `,"ttl":-1`, wantStatus: http.StatusBadRequest},
{name: "below_minimum", ttlJSON: `,"ttl":59`, wantStatus: http.StatusBadRequest},
{name: "above_maximum", ttlJSON: `,"ttl":86401`, wantStatus: http.StatusBadRequest},
{name: "fraction", ttlJSON: `,"ttl":60.5`, wantStatus: http.StatusBadRequest},
{name: "string", ttlJSON: `,"ttl":"auto"`, wantStatus: http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, _ := setupCloudflareLogicDB(t)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(response.ErrorHandlerMiddleware())
router.POST("/groups", CreateGroupHandler)
body := `{"name":"custom","target_mode":"custom","record_type":"CNAME","record_content":"origin.example.com"` + tt.ttlJSON + `}`
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/groups", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != tt.wantStatus {
t.Fatalf("POST /groups(%s) status = %d, body = %s, want %d", tt.name, recorder.Code, recorder.Body.String(), tt.wantStatus)
}
if tt.wantStatus != http.StatusOK {
groups, err := repository.ListCFPointingGroups(ctx)
if err != nil || len(groups) != 1 {
t.Errorf("POST /groups(%s) persisted groups = %d, error = %v, want 1 existing group", tt.name, len(groups), err)
}
return
}
var envelope struct {
Data GroupItem `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode POST /groups(%s) error = %v", tt.name, err)
}
if envelope.Data.TTL != tt.wantTTL {
t.Errorf("POST /groups(%s).ttl = %d, want %d", tt.name, envelope.Data.TTL, tt.wantTTL)
}
stored, err := repository.GetCFPointingGroup(ctx, envelope.Data.ID)
if err != nil {
t.Fatalf("GetCFPointingGroup(%d) error = %v", envelope.Data.ID, err)
}
if stored.TTL != tt.wantTTL {
t.Errorf("persisted POST /groups(%s).ttl = %d, want %d", tt.name, stored.TTL, tt.wantTTL)
}
})
}
}
func TestUpdateGroupTTLAndReadback(t *testing.T) {
ctx, memberID := setupCloudflareLogicDB(t)
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
if err != nil {
t.Fatalf("GetCFPointingMemberContext(%d) error = %v", memberID, err)
}
legacy, err := GetGroup(ctx, state.Group.ID)
if err != nil || legacy.Group.TTL != 300 {
t.Fatalf("GetGroup(legacy) = %+v, error = %v, want ttl 300", legacy, err)
}
dispatchCount := 0
restoreDispatch := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) {
dispatchCount++
return "ttl-task", nil
})
t.Cleanup(restoreDispatch)
ttl := 120
input := GroupInput{Name: state.Group.Name, PrimaryNodeID: state.Group.PrimaryNodeID, Enabled: true, TTL: &ttl}
if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{"sync_status": model.CFMemberSyncOK}); err != nil {
t.Fatalf("UpdateCFPointingMemberColumns(%d) error = %v", memberID, err)
}
updated, err := UpdateGroup(ctx, state.Group.ID, input)
if err != nil || updated.TTL != 120 {
t.Fatalf("UpdateGroup(ttl=120) = %+v, error = %v, want ttl 120", updated, err)
}
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID(%d) error = %v", memberID, err)
}
if member.SyncStatus != model.CFMemberSyncPending || dispatchCount != 1 {
t.Errorf("UpdateGroup(ttl=120) member status = %s, dispatches = %d, want pending and 1", member.SyncStatus, dispatchCount)
}
// 旧客户端省略新增字段时,不应将已设置的手动 TTL 重置为自动。
input.TTL = nil
if _, err := UpdateGroup(ctx, state.Group.ID, input); err != nil {
t.Fatalf("UpdateGroup(omitted ttl) error = %v", err)
}
detail, err := GetGroup(ctx, state.Group.ID)
if err != nil || detail.Group.TTL != 120 {
t.Fatalf("GetGroup(after omitted ttl) = %+v, error = %v, want ttl 120", detail, err)
}
groups, err := ListGroups(ctx)
if err != nil || len(groups) != 1 || groups[0].TTL != 120 {
t.Errorf("ListGroups() = %+v, error = %v, want one group with ttl 120", groups, err)
}
invalidTTL := 0
input.TTL = &invalidTTL
if _, err := UpdateGroup(ctx, state.Group.ID, input); err == nil {
t.Fatal("UpdateGroup(ttl=0) error = nil, want validation error")
}
stored, err := repository.GetCFPointingGroup(ctx, state.Group.ID)
if err != nil || stored.TTL != 120 || dispatchCount != 2 {
t.Errorf("UpdateGroup(invalid ttl) stored = %+v, error = %v, dispatches = %d, want ttl 120 and 2", stored, err, dispatchCount)
}
}
func TestReconcileMemberGroupTTL(t *testing.T) {
tests := []struct {
name string
targetMode string
recordType string
content string
ttl int
proxied bool
wantTTL int
}{
{name: "node_manual", targetMode: "node", recordType: "A", content: "203.0.113.10", ttl: 120, wantTTL: 120},
{name: "cname_manual", targetMode: "custom", recordType: "CNAME", content: "origin.example.com", ttl: 60, wantTTL: 60},
{name: "a_manual", targetMode: "custom", recordType: "A", content: "203.0.113.20", ttl: 600, wantTTL: 600},
{name: "aaaa_manual", targetMode: "custom", recordType: "AAAA", content: "2001:db8::1", ttl: 86400, wantTTL: 86400},
{name: "cname_auto", targetMode: "custom", recordType: "CNAME", content: "origin.example.com", ttl: 1, wantTTL: 1},
{name: "cname_legacy", targetMode: "custom", recordType: "CNAME", content: "origin.example.com", wantTTL: 300},
{name: "cname_proxied", targetMode: "custom", recordType: "CNAME", content: "origin.example.com", ttl: 120, proxied: true, wantTTL: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, memberID := setupCloudflareLogicDB(t)
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
if err != nil {
t.Fatalf("GetCFPointingMemberContext(%d) error = %v", memberID, err)
}
group := state.Group
group.TargetMode, group.RecordType, group.RecordContent, group.TTL = tt.targetMode, tt.recordType, tt.content, tt.ttl
if err := repository.SaveCFPointingGroup(ctx, &group); err != nil {
t.Fatalf("SaveCFPointingGroup(%s) error = %v", tt.name, err)
}
if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{"proxied": tt.proxied}); err != nil {
t.Fatalf("UpdateCFPointingMemberColumns(%s) error = %v", tt.name, err)
}
fake := &fakeClient{}
t.Cleanup(SetClientFactoryForTest(func(string) Client { return fake }))
if err := ReconcileMember(ctx, memberID); err != nil {
t.Fatalf("ReconcileMember(%s) error = %v", tt.name, err)
}
if fake.created == nil {
t.Fatal("ReconcileMember() did not create a DNS record")
}
if got := *fake.created; got.TTL != tt.wantTTL || got.Type != tt.recordType || got.Content != tt.content || got.Proxied != tt.proxied {
t.Errorf("ReconcileMember(%s) input = %+v, want type=%s content=%s proxied=%t ttl=%d", tt.name, got, tt.recordType, tt.content, tt.proxied, tt.wantTTL)
}
})
}
}
type cachedTTLClient struct {
fakeClient
}
func (client *cachedTTLClient) GetRecord(_ context.Context, _, recordID string) (*DNSRecord, error) {
return &DNSRecord{ID: recordID, Type: "A", TTL: 300}, nil
}
func TestReconcileMemberUpdatesGroupTTL(t *testing.T) {
for _, cached := range []bool{false, true} {
t.Run(fmt.Sprintf("cached_%t", cached), func(t *testing.T) {
ctx, memberID := setupCloudflareLogicDB(t)
state, err := repository.GetCFPointingMemberContext(ctx, memberID)
if err != nil {
t.Fatalf("GetCFPointingMemberContext(%d) error = %v", memberID, err)
}
state.Group.TTL = 180
if err := repository.SaveCFPointingGroup(ctx, &state.Group); err != nil {
t.Fatalf("SaveCFPointingGroup(ttl=180) error = %v", err)
}
if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{"proxied": false}); err != nil {
t.Fatalf("UpdateCFPointingMemberColumns(%d) error = %v", memberID, err)
}
fake := &cachedTTLClient{fakeClient: fakeClient{records: []DNSRecord{{ID: "existing", Type: "A", TTL: 300}}}}
if cached {
if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{"cf_record_id": "existing"}); err != nil {
t.Fatalf("UpdateCFPointingMemberColumns(cached) error = %v", err)
}
}
t.Cleanup(SetClientFactoryForTest(func(string) Client { return fake }))
if err := ReconcileMember(ctx, memberID); err != nil {
t.Fatalf("ReconcileMember(cached=%t) error = %v", cached, err)
}
if fake.updated == nil || fake.updated.TTL != 180 || fake.created != nil {
t.Errorf("ReconcileMember(cached=%t) updated = %+v, created = %+v, want update ttl=180 only", cached, fake.updated, fake.created)
}
})
}
}
@@ -39,6 +39,8 @@ type GroupInput struct {
BackupNodeID *uint `json:"backup_node_id"` BackupNodeID *uint `json:"backup_node_id"`
DefaultProxied bool `json:"default_proxied"` DefaultProxied bool `json:"default_proxied"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
// TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。
TTL *int `json:"ttl" example:"1"`
} }
// GroupItem is the admin-facing pointing group summary. // GroupItem is the admin-facing pointing group summary.
@@ -56,6 +58,8 @@ type GroupItem struct {
MemberCount int64 `json:"member_count"` MemberCount int64 `json:"member_count"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
// TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。
TTL int `json:"ttl" example:"1"`
} }
// MemberCreateInput adds a ZoneDomain to a group. Nil Proxied copies the group default. // MemberCreateInput adds a ZoneDomain to a group. Nil Proxied copies the group default.
@@ -0,0 +1,135 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cloudflare
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
func TestWildcardMemberDNSRecords(t *testing.T) {
tests := []struct {
name, mode, recordType, content string
proxied bool
}{
{name: "custom_A", mode: model.CFPointingTargetModeCustom, recordType: "A", content: "203.0.113.20"},
{name: "custom_AAAA", mode: model.CFPointingTargetModeCustom, recordType: "AAAA", content: "2001:db8::20", proxied: true},
{name: "custom_CNAME", mode: model.CFPointingTargetModeCustom, recordType: "CNAME", content: "edge.example.net", proxied: true},
{name: "node_A", recordType: "A", content: "203.0.113.10", proxied: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, originalID := setupCloudflareLogicDB(t)
state, err := repository.GetCFPointingMemberContext(ctx, originalID)
if err != nil {
t.Fatal(err)
}
state.Group.TargetMode, state.Group.RecordType, state.Group.RecordContent = tt.mode, tt.recordType, tt.content
state.Group.DefaultProxied = tt.proxied
if err := repository.SaveCFPointingGroup(ctx, &state.Group); err != nil {
t.Fatal(err)
}
if tt.mode != model.CFPointingTargetModeCustom {
if err := repository.UpdateOpenFlareNodeColumns(ctx, &state.Node, map[string]any{"ip": tt.content}); err != nil {
t.Fatal(err)
}
}
domain := &model.ZoneDomain{ZoneID: state.Zone.ID, Domain: "*.s3.example.com"}
if err := repository.CreateZoneDomain(ctx, domain); err != nil {
t.Fatal(err)
}
available, err := ListAvailableDomains(ctx)
if err != nil || len(available) != 1 || available[0].Domain != domain.Domain {
t.Fatalf("ListAvailableDomains() = %+v, %v, want wildcard domain", available, err)
}
t.Cleanup(SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) { return "task-1", nil }))
member, err := CreateMember(ctx, state.Group.ID, MemberCreateInput{ZoneDomainID: domain.ID})
if err != nil {
t.Fatalf("CreateMember(wildcard) error = %v", err)
}
var creates, updates, deletes atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/zones":
if got := r.URL.Query().Get("name"); got != "example.com" {
t.Errorf("FindZone name = %q, want example.com", got)
}
writeCFTestResponse(t, w, []Zone{{ID: "zone-1", Name: "example.com"}})
case "/zones/zone-1/dns_records", "/zones/zone-1/dns_records/wildcard-record":
if (r.Method == http.MethodPut || r.Method == http.MethodDelete) && r.URL.Path != "/zones/zone-1/dns_records/wildcard-record" {
t.Errorf("DNS %s path = %q, want record ID path", r.Method, r.URL.Path)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
switch r.Method {
case http.MethodGet:
if r.URL.Path == "/zones/zone-1/dns_records/wildcard-record" {
writeCFTestResponse(t, w, DNSRecord{ID: "wildcard-record", Name: domain.Domain, Type: tt.recordType})
return
}
if r.URL.Query().Get("name") != domain.Domain || r.URL.Query().Get("type") != tt.recordType {
t.Errorf("ListRecords query = %v, want wildcard name and %s", r.URL.Query(), tt.recordType)
}
writeCFTestResponse(t, w, []DNSRecord{})
case http.MethodPost, http.MethodPut:
var input RecordInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Errorf("Decode(record) error = %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
wantTTL := 300
if tt.proxied {
wantTTL = 1
}
if input.Name != domain.Domain || input.Type != tt.recordType || input.Content != tt.content || input.Proxied != tt.proxied || input.TTL != wantTTL {
t.Errorf("DNS record input = %+v, want name=%s type=%s content=%s proxied=%t TTL=%d", input, domain.Domain, tt.recordType, tt.content, tt.proxied, wantTTL)
}
if r.Method == http.MethodPost {
creates.Add(1)
} else {
updates.Add(1)
}
writeCFTestResponse(t, w, DNSRecord{ID: "wildcard-record", Name: input.Name, Type: input.Type, Content: input.Content})
case http.MethodDelete:
deletes.Add(1)
writeCFTestResponse(t, w, map[string]string{"id": "wildcard-record"})
default:
t.Errorf("unexpected method %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
}
default:
t.Errorf("unexpected Cloudflare path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
t.Cleanup(SetClientFactoryForTest(func(token string) Client {
return NewHTTPClient(token, WithBaseURL(server.URL), WithHTTPClient(server.Client()))
}))
for range 2 {
if err := ReconcileMember(ctx, member.ID); err != nil {
t.Fatalf("ReconcileMember(wildcard %s) error = %v", tt.recordType, err)
}
}
stored, err := repository.GetCFPointingMemberByID(ctx, member.ID)
if err != nil || stored.SyncStatus != model.CFMemberSyncOK || stored.CFRecordID != "wildcard-record" {
t.Fatalf("synced wildcard member = %+v, error=%v", stored, err)
}
if err := DeleteManagedRecord(ctx, member.ID); err != nil {
t.Fatalf("DeleteManagedRecord(wildcard) error = %v", err)
}
if creates.Load() != 1 || updates.Load() != 1 || deletes.Load() != 1 {
t.Errorf("DNS create/update/delete counts = %d/%d/%d, want 1/1/1", creates.Load(), updates.Load(), deletes.Load())
}
})
}
}
@@ -0,0 +1,65 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
oftls "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/zone"
"github.com/Rain-kl/Wavelet/internal/infra/config"
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"
)
func TestWildcardDomainConfigBundle(t *testing.T) {
t.Cleanup(setupConfigVersionTestDB(t))
ctx := context.Background()
if err := db.DB(ctx).AutoMigrate(&model.TLSCertificate{}); err != nil {
t.Fatal(err)
}
oldSecret := config.Config.App.SessionSecret
config.Config.App.SessionSecret = "wildcard-snapshot-test-secret"
t.Cleanup(func() { config.Config.App.SessionSecret = oldSecret })
certPEM, keyPEM := generateTestCertKeyPairForSnapshotForDomain(t, "*.s3.example.com")
cert, err := oftls.CreateCertificate(ctx, oftls.CertificateInput{Name: "wildcard", CertPEM: certPEM, KeyPEM: keyPEM})
if err != nil {
t.Fatal(err)
}
root, err := zone.Create(ctx, zone.Input{Domain: "example.com"})
if err != nil {
t.Fatal(err)
}
domain, err := zone.CreateDomain(ctx, root.ID, zone.DomainInput{Domain: "*.s3.example.com", CertID: &cert.ID})
if err != nil {
t.Fatalf("CreateDomain(wildcard) error = %v", err)
}
route := &model.ProxyRoute{SiteName: "wildcard-site", OriginURL: "http://origin:8080", Upstreams: `["http://origin:8080"]`, Enabled: true, EnableHTTPS: true, RedirectHTTP: true}
if err := repository.CreateProxyRouteRecord(ctx, route); err != nil {
t.Fatal(err)
}
if err := repository.ReplaceZoneDomainRouteBindings(ctx, route.ID, []uint{domain.ID}); err != nil {
t.Fatal(err)
}
bundle, err := buildCurrentConfigBundle(ctx, true)
if err != nil {
t.Fatalf("buildCurrentConfigBundle(wildcard) error = %v", err)
}
var doc openrestyrender.Document
if err := json.Unmarshal([]byte(bundle.SnapshotJSON), &doc); err != nil {
t.Fatal(err)
}
if len(doc.Routes) != 1 || !slices.Equal(doc.Routes[0].Domains, []string{domain.Domain}) || !slices.Equal(doc.Routes[0].DomainCertIDs, []uint{cert.ID}) {
t.Fatalf("snapshot routes = %+v, want wildcard domain and its certificate", doc.Routes)
}
if !strings.Contains(bundle.RouteConfig, "server_name *.s3.example.com;") || !strings.Contains(bundle.RouteConfig, "listen 443 ssl;") {
t.Errorf("wildcard bundle missing HTTPS server: %s", bundle.RouteConfig)
}
}
@@ -305,9 +305,21 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{ rec := performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": "*.example.com", "domain": "*.example.com",
}, adminAuthHeaders(seed.Token)) }, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusBadRequest, rec.Code) if rec.Code != http.StatusOK {
errResp := decodeAPIResponse(t, rec) t.Fatalf("create wildcard domain status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
assert.NotEmpty(t, errResp.ErrorMsg) }
wildcardData := unmarshalAPIMap(t, requireAPIOK(t, rec).Data)
if wildcardData["domain"] != "*.example.com" {
t.Errorf("created domain = %v, want *.example.com", wildcardData["domain"])
}
for _, invalid := range []string{"a.*.example.com", "*.other.com", "*.example.com;"} {
rejected := performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": invalid,
}, adminAuthHeaders(seed.Token))
if rejected.Code != http.StatusBadRequest || decodeAPIResponse(t, rejected).ErrorMsg == "" {
t.Errorf("create domain %q status = %d, body = %s, want 400 with error message", invalid, rejected.Code, rejected.Body.String())
}
}
rec = performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{ rec = performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": "security.example.com", "cert_id": certID, "domain": "security.example.com", "cert_id": certID,
+10 -4
View File
@@ -229,11 +229,17 @@ func routeMonitorURL(ctx context.Context, route *model.ProxyRoute) (string, erro
if len(domains) == 0 { if len(domains) == 0 {
return "", fmt.Errorf("route %s has no zone domains", route.SiteName) return "", fmt.Errorf("route %s has no zone domains", route.SiteName)
} }
domain := domains[0].Domain // 通配符是匹配规则而非可探测地址;混合绑定时选择具体域名。
if route.EnableHTTPS { for _, domain := range domains {
return "https://" + domain, nil if strings.Contains(domain.Domain, "*") {
continue
}
if route.EnableHTTPS {
return "https://" + domain.Domain, nil
}
return "http://" + domain.Domain, nil
} }
return "http://" + domain, nil return "", fmt.Errorf("route %s only has wildcard domains; bind a concrete hostname for monitoring", route.SiteName)
} }
func monitorPayload(id int, name, targetURL string, config *kumaConfig) map[string]any { func monitorPayload(id int, name, targetURL string, config *kumaConfig) map[string]any {
@@ -0,0 +1,82 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package uptimekuma
import (
"context"
"net/http/httptest"
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
func TestRouteMonitorURLWildcard(t *testing.T) {
t.Cleanup(setupSyncTestDB(t))
ctx := context.Background()
route := &model.ProxyRoute{SiteName: "wildcard-site", EnableHTTPS: true}
if err := repository.CreateProxyRouteRecord(ctx, route); err != nil {
t.Fatal(err)
}
domain := &model.ZoneDomain{Domain: "*.example.com", ProxyRouteID: &route.ID}
if err := repository.CreateZoneDomain(ctx, domain); err != nil {
t.Fatal(err)
}
if got, err := routeMonitorURL(ctx, route); got != "" || err == nil || !strings.Contains(err.Error(), "wildcard") {
t.Fatalf("routeMonitorURL(wildcard only) = (%q, %v), want empty URL and wildcard error", got, err)
}
// 无可探测地址时不能调用客户端,也不能将已有监控标记为过期。
expected := syncRouteMonitors(ctx, nil, []*model.ProxyRoute{route}, nil, 0, nil)
if !expected[route.SiteName] {
t.Error("syncRouteMonitors(wildcard only) removed route from expected sites")
}
if err := repository.CreateZoneDomain(ctx, &model.ZoneDomain{Domain: "www.example.com", ProxyRouteID: &route.ID}); err != nil {
t.Fatal(err)
}
if got, err := routeMonitorURL(ctx, route); err != nil || got != "https://www.example.com" {
t.Errorf("routeMonitorURL(mixed HTTPS) = (%q, %v), want https://www.example.com", got, err)
}
route.EnableHTTPS = false
if got, err := routeMonitorURL(ctx, route); err != nil || got != "http://www.example.com" {
t.Errorf("routeMonitorURL(mixed HTTP) = (%q, %v), want http://www.example.com", got, err)
}
}
// TestSyncWildcardPreservesExistingMonitor exercises cleanup through a full sync.
func TestSyncWildcardPreservesExistingMonitor(t *testing.T) {
t.Cleanup(setupSyncTestDB(t))
ctx := context.Background()
route := &model.ProxyRoute{SiteName: "wildcard-site", Enabled: true}
if err := repository.CreateProxyRouteRecord(ctx, route); err != nil {
t.Fatal(err)
}
createRouteZoneDomain(t, ctx, route, "*.example.com")
mockSrv := newMockKumaServer(`{"98":{"id":98,"name":"wildcard-site","url":"https://old.example.com","tags":[{"id":10,"name":"OpenFlare"}]},"99":{"id":99,"name":"stale-site","tags":[{"id":10,"name":"OpenFlare"}]}}`)
server := httptest.NewServer(mockSrv)
t.Cleanup(server.Close)
setTestConfig(ctx, model.ConfigKeyUptimeKumaEnabled, "true")
setTestConfig(ctx, model.ConfigKeyUptimeKumaURL, server.URL)
setTestConfig(ctx, model.ConfigKeyUptimeKumaUsername, "admin")
setTestConfig(ctx, model.ConfigKeyUptimeKumaPassword, "password")
setTestConfig(ctx, model.ConfigKeyUptimeKumaMonitorScope, "all")
if err := SyncToUptimeKuma(ctx); err != nil {
t.Fatal(err)
}
mockSrv.mu.Lock()
posts := append([]string(nil), mockSrv.postsReceived...)
mockSrv.mu.Unlock()
deletedStale := false
for _, body := range posts {
if strings.Contains(body, `"editMonitor"`) || strings.Contains(body, `"add"`) || (strings.Contains(body, `"deleteMonitor"`) && strings.Contains(body, "98")) {
t.Errorf("SyncToUptimeKuma(wildcard only) sent monitor mutation %q", body)
}
if strings.Contains(body, `"deleteMonitor"`) && strings.Contains(body, "99") {
deletedStale = true
}
}
if !deletedStale {
t.Error("SyncToUptimeKuma did not delete stale monitor 99; cleanup must run while preserving wildcard monitor 98")
}
}
+11 -12
View File
@@ -4,16 +4,15 @@
package zone package zone
const ( const (
errZoneDomainRequired = "domain name must not be empty" errZoneDomainRequired = "domain name must not be empty"
errZoneRootInvalid = "zone must be a valid registered root domain" errZoneRootInvalid = "zone must be a valid registered root domain"
errDomainInvalid = "invalid domain name format" errDomainInvalid = "invalid domain name format"
errDomainWildcardUnsupported = "wildcard domain names are not supported" errDomainOutsideZone = "domain does not belong to this zone"
errDomainOutsideZone = "domain does not belong to this zone" errZoneNotFound = "Zone was not found"
errZoneNotFound = "Zone was not found" errDomainNotFound = "Domain was not found"
errDomainNotFound = "Domain was not found" errDomainExists = "domain already exists"
errDomainExists = "domain already exists" errCertificateNotFound = "selected certificate was not found"
errCertificateNotFound = "selected certificate was not found" errDomainBoundToRoute = "domain is bound to a proxy route; remove the binding first"
errDomainBoundToRoute = "domain is bound to a proxy route; remove the binding first" errZoneHasDomains = "root domain still contains domains; delete them all first"
errZoneHasDomains = "root domain still contains domains; delete them all first" errStatsRangeInvalid = "invalid time range; select 24h, 7d or 30d"
errStatsRangeInvalid = "invalid time range; select 24h, 7d or 30d"
) )
+27 -5
View File
@@ -13,6 +13,7 @@ import (
"strings" "strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity" "github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity"
"golang.org/x/net/idna"
) )
// ImportReport describes the idempotent legacy migration result. // ImportReport describes the idempotent legacy migration result.
@@ -82,7 +83,7 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo
} }
for _, item := range items { for _, item := range items {
domain, normErr := normalizeDomain(item.Domain) domain, normErr := normalizeLegacyDomain(item.Domain)
if normErr != nil { if normErr != nil {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", item.Domain, normErr)) report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", item.Domain, normErr))
continue continue
@@ -95,22 +96,28 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo
var existingID uint var existingID uint
var existingZoneDomain string var existingZoneDomain string
var existingRouteID, existingCertID sql.Null[uint]
scanErr := tx.QueryRowContext(ctx, q(` scanErr := tx.QueryRowContext(ctx, q(`
SELECT zd.id, z.domain SELECT zd.id, z.domain, zd.proxy_route_id, zd.cert_id
FROM of_zone_domains zd FROM of_zone_domains zd
JOIN of_zones z ON z.id = zd.zone_id JOIN of_zones z ON z.id = zd.zone_id
WHERE zd.domain = ? WHERE zd.domain = ?
`), domain).Scan(&existingID, &existingZoneDomain) `), domain).Scan(&existingID, &existingZoneDomain, &existingRouteID, &existingCertID)
if scanErr == nil { if scanErr == nil {
if (item.ProxyRouteID != nil && existingRouteID.Valid && existingRouteID.V != *item.ProxyRouteID) ||
(item.CertID != nil && existingCertID.Valid && existingCertID.V != *item.CertID) {
report.Conflicts = append(report.Conflicts, domain+": canonical domain binding conflict")
continue
}
if existingZoneDomain != root { if existingZoneDomain != root {
report.Conflicts = append(report.Conflicts, domain+": global domain conflict") report.Conflicts = append(report.Conflicts, domain+": global domain conflict")
} else if item.ProxyRouteID != nil { } else if item.ProxyRouteID != nil || item.CertID != nil {
if _, bindErr := tx.ExecContext(ctx, q(` if _, bindErr := tx.ExecContext(ctx, q(`
UPDATE of_zone_domains UPDATE of_zone_domains
SET proxy_route_id = COALESCE(proxy_route_id, ?), SET proxy_route_id = COALESCE(proxy_route_id, ?),
cert_id = COALESCE(cert_id, ?) cert_id = COALESCE(cert_id, ?)
WHERE id = ? WHERE id = ?
`), *item.ProxyRouteID, nullableUint(item.CertID), existingID); bindErr != nil { `), nullableUint(item.ProxyRouteID), nullableUint(item.CertID), existingID); bindErr != nil {
return report, bindErr return report, bindErr
} }
} }
@@ -152,6 +159,21 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo
return report, nil return report, nil
} }
// normalizeLegacyDomain canonicalizes historical IDNs for DNS identity and rendering.
// Certificate associations and route bindings remain attached to the imported row.
func normalizeLegacyDomain(raw string) (string, error) {
domain := strings.ToLower(strings.TrimSpace(raw))
host := strings.TrimPrefix(domain, "*.")
ascii, err := idna.Lookup.ToASCII(host)
if err != nil {
return "", errors.New(errDomainInvalid)
}
if host != domain {
ascii = "*." + ascii
}
return normalizeDomain(ascii)
}
func ensureZone( func ensureZone(
ctx context.Context, ctx context.Context,
tx *sql.Tx, tx *sql.Tx,
@@ -148,3 +148,117 @@ func TestImportLegacyTxNoOpWithoutLegacyColumns(t *testing.T) {
assert.Equal(t, 0, report.Zones) assert.Equal(t, 0, report.Zones)
assert.Equal(t, 0, report.Domains) assert.Equal(t, 0, report.Domains)
} }
// TestImportLegacyTxUnicodeDomains canonicalizes historical names and preserves bindings on upgrade.
func TestImportLegacyTxUnicodeDomains(t *testing.T) {
for _, source := range []string{"route", "managed"} {
t.Run(source, func(t *testing.T) {
sqlDB, cleanup := setupLegacyImportDB(t)
t.Cleanup(cleanup)
ctx := context.Background()
if _, err := sqlDB.Exec(`INSERT INTO of_tls_certificates (id, name) VALUES (7, 'cert')`); err != nil {
t.Fatal(err)
}
var err error
if source == "route" {
_, err = sqlDB.Exec(`INSERT INTO of_proxy_routes (id, domain, domains, domain_cert_ids) VALUES (3, 'api.bücher.de', '["api.bücher.de"]', '[7]')`)
} else {
_, err = sqlDB.Exec(`INSERT INTO of_managed_domains (domain, cert_id) VALUES ('api.bücher.de', 7)`)
}
if err != nil {
t.Fatal(err)
}
for attempt := range 2 {
tx, err := sqlDB.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = tx.Rollback() })
report, err := ImportLegacyTx(ctx, tx, false)
if err != nil {
t.Fatalf("ImportLegacyTx(%s, attempt=%d) = %+v, %v, want no conflicts", source, attempt, report, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
want := 1 - attempt
if report.Zones != want || report.Domains != want {
t.Errorf("ImportLegacyTx(%s, attempt=%d) = %+v, want %d zones/domains", source, attempt, report, want)
}
}
var root, domain string
var certID uint
var routeID sql.NullInt64
if err := sqlDB.QueryRow(`SELECT z.domain, zd.domain, zd.cert_id, zd.proxy_route_id FROM of_zone_domains zd JOIN of_zones z ON z.id = zd.zone_id`).Scan(&root, &domain, &certID, &routeID); err != nil {
t.Fatal(err)
}
if root != "xn--bcher-kva.de" || domain != "api.xn--bcher-kva.de" || certID != 7 {
t.Errorf("imported %s = (%q, %q, cert=%d), want (xn--bcher-kva.de, api.xn--bcher-kva.de, cert=7)", source, root, domain, certID)
}
if (source == "route" && (!routeID.Valid || routeID.Int64 != 3)) || (source == "managed" && routeID.Valid) {
t.Errorf("imported %s route binding = %v", source, routeID)
}
var zoneID uint
if err := sqlDB.QueryRow(`SELECT id FROM of_zones`).Scan(&zoneID); err != nil {
t.Fatal(err)
}
if _, err := Create(ctx, Input{Domain: "xn--bcher-kva.de"}); err == nil {
t.Error("Create accepted an equivalent imported IDN zone")
}
if _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "api.xn--bcher-kva.de"}); err == nil {
t.Error("CreateDomain accepted an equivalent imported IDN host")
}
if _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "www.xn--bcher-kva.de"}); err != nil {
t.Errorf("CreateDomain in imported IDN zone: %v", err)
}
if _, err := normalizeDomain("api.bücher.de"); err == nil {
t.Error("normalizeDomain(api.bücher.de) accepted a new Unicode domain")
}
})
}
}
// TestImportLegacyTxIDNBindingConflict rejects equivalent names bound to different routes.
func TestImportLegacyTxIDNBindingConflict(t *testing.T) {
sqlDB, cleanup := setupLegacyImportDB(t)
t.Cleanup(cleanup)
if _, err := sqlDB.Exec(`INSERT INTO of_proxy_routes (id, domain, domains) VALUES (3, 'api.bücher.de', '["api.bücher.de"]'), (4, 'api.xn--bcher-kva.de', '["api.xn--bcher-kva.de"]')`); err != nil {
t.Fatal(err)
}
tx, err := sqlDB.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = tx.Rollback() })
report, err := ImportLegacyTx(context.Background(), tx, false)
if err == nil || len(report.Conflicts) != 1 {
t.Errorf("ImportLegacyTx(equivalent IDN with different bindings) = %+v, %v; want one conflict", report, err)
}
}
// TestNormalizeLegacyIDNRoot resolves Unicode public suffixes through their ASCII identity.
func TestNormalizeLegacyIDNRoot(t *testing.T) {
domain, err := normalizeLegacyDomain("api.example.公司.cn")
if err != nil {
t.Fatal(err)
}
root, err := zoneRoot(domain)
if err != nil || root != "example.xn--55qx5d.cn" {
t.Errorf("zoneRoot(%q) = %q, %v; want example.xn--55qx5d.cn", domain, root, err)
}
}
// TestNormalizeLegacyIDNCanonicalForms keeps Unicode normalization and wildcard identity consistent.
func TestNormalizeLegacyIDNCanonicalForms(t *testing.T) {
for _, raw := range []string{"api.bücher.de", "api.bu\u0308cher.de", "API.XN--BCHER-KVA.DE"} {
domain, err := normalizeLegacyDomain(raw)
if err != nil || domain != "api.xn--bcher-kva.de" {
t.Errorf("normalizeLegacyDomain(%q) = %q, %v; want api.xn--bcher-kva.de", raw, domain, err)
}
}
domain, err := normalizeLegacyDomain("*.bücher.de")
if err != nil || domain != "*.xn--bcher-kva.de" {
t.Errorf("normalizeLegacyDomain(*.bücher.de) = %q, %v; want *.xn--bcher-kva.de", domain, err)
}
}
+19 -7
View File
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package zone manages registered roots and their explicit hostnames. // Package zone manages registered roots and their exact or wildcard hostnames.
package zone package zone
import ( import (
@@ -18,6 +18,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const maxDomainLength = 253
// Input is the mutable Zone payload. // Input is the mutable Zone payload.
type Input struct { type Input struct {
Domain string `json:"domain"` Domain string `json:"domain"`
@@ -45,7 +47,8 @@ type ListItem struct {
} }
func zoneRoot(domain string) (string, error) { func zoneRoot(domain string) (string, error) {
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) domain = strings.ToLower(strings.TrimSpace(domain))
return publicsuffix.EffectiveTLDPlusOne(strings.TrimPrefix(domain, "*."))
} }
func normalizeDomain(raw string) (string, error) { func normalizeDomain(raw string) (string, error) {
@@ -53,12 +56,21 @@ func normalizeDomain(raw string) (string, error) {
if domain == "" { if domain == "" {
return "", errors.New(errZoneDomainRequired) return "", errors.New(errZoneDomainRequired)
} }
if strings.Contains(domain, "*") { // 只允许最左侧完整标签为通配符;其余字符必须是 DNS 标签,不能进入配置语法。
return "", errors.New(errDomainWildcardUnsupported) host := strings.TrimPrefix(domain, "*.")
} if len(domain) > maxDomainLength {
if strings.Contains(domain, "://") || strings.Contains(domain, "/") || strings.Contains(domain, "?") || strings.Contains(domain, "#") || strings.Contains(domain, "@") {
return "", errors.New(errDomainInvalid) return "", errors.New(errDomainInvalid)
} }
for _, label := range strings.Split(host, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", errors.New(errDomainInvalid)
}
for _, char := range label {
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
return "", errors.New(errDomainInvalid)
}
}
}
if _, err := zoneRoot(domain); err != nil { if _, err := zoneRoot(domain); err != nil {
return "", errors.New(errDomainInvalid) return "", errors.New(errDomainInvalid)
} }
@@ -151,7 +163,7 @@ func GetOverview(ctx context.Context, id uint) (*Overview, error) {
return &Overview{Zone: *zone, Domains: domains}, nil return &Overview{Zone: *zone, Domains: domains}, nil
} }
// CreateDomain adds a validated exact hostname to a Zone. // CreateDomain adds a validated exact or wildcard hostname to a Zone.
func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error) { func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error) {
zone, err := repository.GetZoneByID(ctx, zoneID) zone, err := repository.GetZoneByID(ctx, zoneID)
if err != nil { if err != nil {
+90 -4
View File
@@ -5,6 +5,7 @@ package zone
import ( import (
"context" "context"
"strings"
"testing" "testing"
"time" "time"
@@ -28,12 +29,97 @@ func setupZoneDB(t *testing.T) context.Context {
return context.Background() return context.Background()
} }
func TestCreateZoneDomainRejectsWildcard(t *testing.T) { func TestNormalizeDomainWildcard(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: " *.Example.COM ", want: "*.example.com"},
{input: "*.s3.example.com", want: "*.s3.example.com"},
{input: "*.example.co.uk", want: "*.example.co.uk"},
{input: "API.Example.com", want: "api.example.com"},
{input: "example.com", want: "example.com"},
{input: "*"}, {input: "*.com"}, {input: "*.co.uk"},
{input: "foo*.example.com"}, {input: "a.*.example.com"},
{input: "*.*.example.com"}, {input: "**.example.com"},
{input: "*.example.com."}, {input: "*..example.com"},
{input: "*.-s3.example.com"}, {input: "*.s3-.example.com"},
{input: "*.s_3.example.com"}, {input: "*.example.com:443"},
{input: "*.example.com;"}, {input: "*.a b.example.com"},
{input: "*.a\nb.example.com"}, {input: "https://*.example.com"},
{input: "*.example.com/path"}, {input: "*.example.com?query"},
{input: "*.example.com#fragment"}, {input: "*@example.com"},
{input: "*." + strings.Repeat("a", 64) + ".example.com"},
{input: "*." + strings.Repeat(strings.Repeat("a", 63)+".", 4) + "com"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := normalizeDomain(tt.input)
if got != tt.want || (err != nil) != (tt.want == "") {
t.Errorf("normalizeDomain(%q) = (%q, %v), want %q with error=%t", tt.input, got, err, tt.want, tt.want == "")
}
})
}
}
func TestCreateAndUpdateWildcardDomain(t *testing.T) {
ctx := setupZoneDB(t) ctx := setupZoneDB(t)
zone, err := Create(ctx, Input{Domain: "example.com"}) zone, err := Create(ctx, Input{Domain: "example.com"})
require.NoError(t, err) if err != nil {
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.example.com"}) t.Fatal(err)
require.EqualError(t, err, errDomainWildcardUnsupported) }
item, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: " *.Example.COM "})
if err != nil {
t.Fatalf("CreateDomain(wildcard) error = %v", err)
}
if item.Domain != "*.example.com" {
t.Errorf("CreateDomain(wildcard).Domain = %q, want *.example.com", item.Domain)
}
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.example.com"}); err == nil {
t.Error("CreateDomain(duplicate wildcard) error = nil, want duplicate error")
}
// 精确域名可以和通配符共存,路由匹配优先级交给 OpenResty。
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"}); err != nil {
t.Fatalf("CreateDomain(exact alongside wildcard) error = %v", err)
}
for _, domain := range []string{"*.s3.example.com", "www.example.com", "*.example.com"} {
updated, err := UpdateDomain(ctx, zone.ID, item.ID, DomainInput{Domain: domain})
if err != nil {
t.Fatalf("UpdateDomain(%q) error = %v", domain, err)
}
if updated.Domain != domain {
t.Errorf("UpdateDomain(%q).Domain = %q", domain, updated.Domain)
}
}
for _, domain := range []string{"*.other.com", "*.example.com.evil.net", "a.*.example.com"} {
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: domain}); err == nil {
t.Errorf("CreateDomain(%q) error = nil, want invalid/outside-zone error", domain)
}
if _, err := UpdateDomain(ctx, zone.ID, item.ID, DomainInput{Domain: domain}); err == nil {
t.Errorf("UpdateDomain(%q) error = nil, want invalid/outside-zone error", domain)
}
}
stored, err := repository.GetZoneDomainByZoneAndID(ctx, zone.ID, item.ID)
if err != nil || stored.Domain != "*.example.com" {
t.Fatalf("stored domain after rejected updates = %+v, error=%v, want *.example.com", stored, err)
}
}
func TestZoneRootRejectsWildcard(t *testing.T) {
ctx := setupZoneDB(t)
if _, err := Create(ctx, Input{Domain: "*.example.com"}); err == nil {
t.Error("Create(wildcard zone) error = nil, want invalid root error")
}
zone, err := Create(ctx, Input{Domain: "example.co.uk"})
if err != nil {
t.Fatal(err)
}
if _, err := Update(ctx, zone.ID, Input{Domain: "*.example.co.uk"}); err == nil {
t.Error("Update(wildcard zone) error = nil, want invalid root error")
}
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.s3.example.co.uk"}); err != nil {
t.Errorf("CreateDomain(wildcard under multi-label public suffix) error = %v", err)
}
} }
func TestDeleteDomainRejectsBoundRoute(t *testing.T) { func TestDeleteDomainRejectsBoundRoute(t *testing.T) {
+3 -1
View File
@@ -161,8 +161,9 @@ func DeleteHandler(c *gin.Context) {
c.JSON(http.StatusOK, response.OKNil()) c.JSON(http.StatusOK, response.OKNil())
} }
// CreateDomainHandler creates an explicit FQDN under a Zone. // CreateDomainHandler creates an exact or wildcard FQDN under a Zone.
// @Summary 创建 Zone 域名 // @Summary 创建 Zone 域名
// @Description 支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。
// @Tags openflare-zone // @Tags openflare-zone
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -192,6 +193,7 @@ func CreateDomainHandler(c *gin.Context) {
// UpdateDomainHandler updates a Zone domain. // UpdateDomainHandler updates a Zone domain.
// @Summary 更新 Zone 域名 // @Summary 更新 Zone 域名
// @Description 支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。
// @Tags openflare-zone // @Tags openflare-zone
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -0,0 +1,105 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/google/go-cmp/cmp"
)
func TestGetStatsWildcardHosts(t *testing.T) {
type totals struct {
Requests int64
Visitors int64
Bytes int64
}
tests := []struct {
name string
domains []string
want totals
buckets map[int]totals
}{
{
name: "wildcard_matches_multiple_levels_without_root",
domains: []string{"*.example.com"}, want: totals{Requests: 5, Visitors: 3, Bytes: 150},
buckets: map[int]totals{0: {Requests: 2, Visitors: 1, Bytes: 30}, 1: {Requests: 3, Visitors: 2, Bytes: 120}},
},
{
name: "nested_wildcard_excludes_its_suffix_root",
domains: []string{"*.s3.example.com"}, want: totals{Requests: 2, Visitors: 1, Bytes: 70},
buckets: map[int]totals{1: {Requests: 2, Visitors: 1, Bytes: 70}},
},
{
name: "overlapping_exact_and_wildcards_are_not_summed_twice",
domains: []string{"api.example.com", "*.example.com", "*.s3.example.com"}, want: totals{Requests: 5, Visitors: 3, Bytes: 150},
buckets: map[int]totals{0: {Requests: 2, Visitors: 1, Bytes: 30}, 1: {Requests: 3, Visitors: 2, Bytes: 120}},
},
{
name: "exact_root_can_be_included_explicitly",
domains: []string{"example.com", "*.s3.example.com"}, want: totals{Requests: 3, Visitors: 2, Bytes: 130},
buckets: map[int]totals{0: {Requests: 1, Visitors: 1, Bytes: 60}, 1: {Requests: 2, Visitors: 1, Bytes: 70}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := setupZoneDB(t)
testhelper.SetupLogStoresForTest(t)
site, err := Create(ctx, Input{Domain: "example.com"})
if err != nil {
t.Fatalf("Create(example.com) error = %v, want nil", err)
}
// 直接登记域名,隔离流量统计与业务层域名校验的回归范围。
for _, domain := range tt.domains {
if err := repository.CreateZoneDomain(ctx, &model.ZoneDomain{ZoneID: site.ID, Domain: domain}); err != nil {
t.Fatalf("CreateZoneDomain(%q) error = %v, want nil", domain, err)
}
}
base := time.Now().UTC().Truncate(time.Hour).Add(-3 * time.Hour)
rows := []*model.OpenFlareAccessLog{
{Host: "a..example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "5.5.5.5", BytesSent: 1000},
{Host: ".a.example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "5.5.5.5", BytesSent: 1000},
{Host: "api.example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "1.1.1.1", BytesSent: 10},
{Host: " Deep.API.Example.COM ", LoggedAt: base.Add(2 * time.Minute), RemoteAddr: "1.1.1.1", BytesSent: 20},
{Host: "bucket.s3.example.com", LoggedAt: base.Add(time.Hour + time.Minute), RemoteAddr: "2.2.2.2", BytesSent: 30},
{Host: "deep.bucket.s3.example.com", LoggedAt: base.Add(time.Hour + 2*time.Minute), BytesSent: 40},
{Host: "s3.example.com", LoggedAt: base.Add(time.Hour + 3*time.Minute), RemoteAddr: "3.3.3.3", BytesSent: 50},
{Host: "example.com", LoggedAt: base.Add(3 * time.Minute), RemoteAddr: "1.1.1.1", BytesSent: 60},
{Host: "evil-example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "9.9.9.9", BytesSent: 70},
{Host: "bucket.s3.example.com.evil", LoggedAt: base.Add(time.Minute), RemoteAddr: "9.9.9.9", BytesSent: 80},
{Host: "bucket.s3.example.com", LoggedAt: base.Add(-48 * time.Hour), RemoteAddr: "9.9.9.9", BytesSent: 90},
}
if err := repository.InsertOpenFlareAccessLogsBatch(ctx, rows); err != nil {
t.Fatalf("InsertOpenFlareAccessLogsBatch(wildcard fixtures) error = %v, want nil", err)
}
stats, err := GetStats(ctx, site.ID, "24h")
if err != nil {
t.Fatalf("GetStats(%d, 24h) error = %v, want nil", site.ID, err)
}
if got := (totals{Requests: stats.RequestCount, Visitors: stats.UniqueVisitors, Bytes: stats.BytesSent}); got != tt.want {
t.Errorf("GetStats(domains=%v) totals = %+v, want %+v", tt.domains, got, tt.want)
}
if !stats.Available || stats.DomainCount != len(tt.domains) {
t.Errorf("GetStats(domains=%v) available/domain_count = %t/%d, want true/%d", tt.domains, stats.Available, stats.DomainCount, len(tt.domains))
}
wantBuckets := make(map[int64]totals)
for offset, total := range tt.buckets {
wantBuckets[base.Add(time.Duration(offset)*time.Hour).Unix()] = total
}
gotBuckets := make(map[int64]totals)
for _, point := range stats.Series {
if point.RequestCount != 0 || point.UniqueVisitors != 0 || point.BytesSent != 0 {
gotBuckets[point.BucketStartedAt.Unix()] = totals{Requests: point.RequestCount, Visitors: point.UniqueVisitors, Bytes: point.BytesSent}
}
}
if diff := cmp.Diff(wantBuckets, gotBuckets); diff != "" {
t.Errorf("GetStats(domains=%v) nonempty series mismatch (-want +got):\n%s", tt.domains, diff)
}
})
}
}
+4 -3
View File
@@ -33,9 +33,10 @@ var rootCmd = &cobra.Command{
Compress: config.Config.Log.Compress, Compress: config.Config.Log.Compress,
}) })
trace.Init(trace.Config{ trace.Init(trace.Config{
AppName: config.Config.App.AppName, AppName: config.Config.App.AppName,
SamplingRate: config.Config.Otel.SamplingRate, SamplingRate: config.Config.Otel.SamplingRate,
TracerName: config.Config.Otel.TracerName, TracerName: config.Config.Otel.TracerName,
ExportProtocol: config.Config.Otel.ExportProtocol,
}) })
}, },
PreRun: func(_ *cobra.Command, _ []string) { PreRun: func(_ *cobra.Command, _ []string) {
+8
View File
@@ -273,6 +273,14 @@ func applyEnvOverrides(c *configModel) {
c.Log.Output = envStr("LOG_OUTPUT", c.Log.Output) c.Log.Output = envStr("LOG_OUTPUT", c.Log.Output)
// ─── OTel ─── // ─── OTel ───
protocol := strings.TrimSpace(envStr("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", ""))
if protocol == "" {
protocol = strings.TrimSpace(envStr("OTEL_EXPORTER_OTLP_PROTOCOL", ""))
}
if protocol == "" {
protocol = c.Otel.ExportProtocol
}
c.Otel.ExportProtocol = strings.ToLower(strings.TrimSpace(protocol))
c.Otel.SamplingRate = envFloat64("OTEL_SAMPLING_RATE", c.Otel.SamplingRate) c.Otel.SamplingRate = envFloat64("OTEL_SAMPLING_RATE", c.Otel.SamplingRate)
c.Otel.TracerName = envStr("OTEL_TRACER_NAME", c.Otel.TracerName) c.Otel.TracerName = envStr("OTEL_TRACER_NAME", c.Otel.TracerName)
+21
View File
@@ -47,3 +47,24 @@ func TestApplyClickHouseDefaultsEnablesWhenConfigured(t *testing.T) {
t.Fatalf("hosts default not applied: %v", cfg.ClickHouse.Hosts) t.Fatalf("hosts default not applied: %v", cfg.ClickHouse.Hosts)
} }
} }
// TestOTLPProtocolPrecedence covers signal-specific, general, YAML and default settings.
func TestOTLPProtocolPrecedence(t *testing.T) {
for _, tt := range []struct{ name, signal, general, configured, want string }{
{name: "default"},
{name: "yaml", configured: "http/protobuf", want: "http/protobuf"},
{name: "general", general: "http/protobuf", configured: "grpc", want: "http/protobuf"},
{name: "signal", signal: " HTTP/PROTOBUF ", general: "grpc", want: "http/protobuf"},
{name: "empty_signal", signal: " ", general: "grpc", configured: "http/protobuf", want: "grpc"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", tt.signal)
t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", tt.general)
cfg := &configModel{Otel: otelConfig{ExportProtocol: tt.configured}}
applyEnvOverrides(cfg)
if cfg.Otel.ExportProtocol != tt.want {
t.Errorf("OTLP protocol = %q, want %q", cfg.Otel.ExportProtocol, tt.want)
}
})
}
}
+3 -2
View File
@@ -138,6 +138,7 @@ type QueueConfig struct {
// otelConfig OpenTelemetry 配置 // otelConfig OpenTelemetry 配置
type otelConfig struct { type otelConfig struct {
SamplingRate float64 `mapstructure:"sampling_rate"` ExportProtocol string `mapstructure:"export_protocol"`
TracerName string `mapstructure:"tracer_name"` SamplingRate float64 `mapstructure:"sampling_rate"`
TracerName string `mapstructure:"tracer_name"`
} }
@@ -0,0 +1,86 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package migrator
import (
"bytes"
"io/fs"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"github.com/pressly/goose/v3"
"gorm.io/gorm"
)
func TestCloudflareGroupTTLMigrationsMatch(t *testing.T) {
postgresSQL, err := fs.ReadFile(migrationFS, "goose/postgres/202611080001_add_cloudflare_group_ttl.sql")
if err != nil {
t.Fatalf("read PostgreSQL TTL migration: %v", err)
}
sqliteSQL, err := fs.ReadFile(migrationFS, "goose/sqlite/202611080001_add_cloudflare_group_ttl.sql")
if err != nil {
t.Fatalf("read SQLite TTL migration: %v", err)
}
if !bytes.Equal(postgresSQL, sqliteSQL) {
t.Error("Cloudflare TTL migrations differ, want identical portable ADD/DROP COLUMN statements")
}
}
func TestCloudflareGroupTTLMigrationSQLiteUpDownUp(t *testing.T) {
conn, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "cloudflare-ttl.db")), &gorm.Config{})
if err != nil {
t.Fatalf("gorm.Open(sqlite) error = %v", err)
}
sqlDB, err := conn.DB()
if err != nil {
t.Fatalf("conn.DB() error = %v", err)
}
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("sqlDB.Close() error = %v", err)
}
})
goose.SetBaseFS(migrationFS)
if err := goose.SetDialect(dialectSqlite); err != nil {
t.Fatalf("goose.SetDialect(sqlite) error = %v", err)
}
const previousVersion = int64(202610260002)
const ttlVersion = int64(202611080001)
if err := goose.UpTo(sqlDB, "goose/sqlite", previousVersion); err != nil {
t.Fatalf("goose.UpTo(previous) error = %v", err)
}
if err := conn.Exec("INSERT INTO of_cf_pointing_groups (name, primary_node_id, active_node_id) VALUES ('legacy', 0, 0)").Error; err != nil {
t.Fatalf("insert legacy group error = %v", err)
}
for cycle := range 2 {
if err := goose.UpTo(sqlDB, "goose/sqlite", ttlVersion); err != nil {
t.Fatalf("goose.UpTo(ttl), cycle %d error = %v", cycle, err)
}
var ttl int
if err := conn.Raw("SELECT ttl FROM of_cf_pointing_groups WHERE name = 'legacy'").Scan(&ttl).Error; err != nil {
t.Fatalf("read migrated ttl, cycle %d error = %v", cycle, err)
}
if ttl != 0 {
t.Errorf("migrated legacy ttl, cycle %d = %d, want compatibility sentinel 0", cycle, ttl)
}
if conn.Migrator().HasColumn("of_cf_pointing_members", "ttl") {
t.Error("TTL migration added member-level ttl, want group-level only")
}
if err := conn.Exec("UPDATE of_cf_pointing_groups SET ttl = 120 WHERE name = 'legacy'").Error; err != nil {
t.Fatalf("write manual ttl error = %v", err)
}
if err := goose.DownTo(sqlDB, "goose/sqlite", previousVersion); err != nil {
t.Fatalf("goose.DownTo(previous), cycle %d error = %v", cycle, err)
}
if conn.Migrator().HasColumn("of_cf_pointing_groups", "ttl") {
t.Errorf("goose.DownTo(previous), cycle %d left ttl column", cycle)
}
var count int64
if err := conn.Table("of_cf_pointing_groups").Where("name = ?", "legacy").Count(&count).Error; err != nil || count != 1 {
t.Fatalf("legacy groups after rollback = %d, error = %v, want 1", count, err)
}
}
}
@@ -0,0 +1,6 @@
-- +goose Up
-- 零值由业务层解释为旧分组的 300 秒 TTL;新建分组显式保存所选值。
ALTER TABLE of_cf_pointing_groups ADD COLUMN ttl INTEGER NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE of_cf_pointing_groups DROP COLUMN ttl;
@@ -0,0 +1,6 @@
-- +goose Up
-- 零值由业务层解释为旧分组的 300 秒 TTL;新建分组显式保存所选值。
ALTER TABLE of_cf_pointing_groups ADD COLUMN ttl INTEGER NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE of_cf_pointing_groups DROP COLUMN ttl;
+1 -1
View File
@@ -24,7 +24,7 @@ type NodeAccessLogFilter struct {
NodeID string NodeID string
RemoteAddr string RemoteAddr string
Host string Host string
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes. // Hosts 优先于 Host:具体域名精确匹配(忽略大小写),*.suffix 匹配非空、多层子域名但不含 suffix 本身。
Hosts []string Hosts []string
Path string Path string
// StatusCode filters by exact HTTP status code when > 0. // StatusCode filters by exact HTTP status code when > 0.
+2
View File
@@ -60,6 +60,8 @@ type CFPointingGroup struct {
Enabled bool `json:"enabled" gorm:"not null;default:false"` Enabled bool `json:"enabled" gorm:"not null;default:false"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
// TTL 为 1 时表示自动;零值兼容旧分组的 300 秒 TTL。
TTL int `json:"ttl" gorm:"not null;default:0"`
} }
// TableName returns the Cloudflare pointing group table name. // TableName returns the Cloudflare pointing group table name.
+7 -4
View File
@@ -160,7 +160,9 @@ type OpenFlareAccessLogQuery struct {
NodeID string NodeID string
RemoteAddr string RemoteAddr string
Host string Host string
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes. // Hosts matches case-insensitively and takes precedence over Host.
// Exact names match only that host; *.example.com matches one or more
// nonempty subdomain levels, excluding example.com itself.
Hosts []string Hosts []string
Path string Path string
// StatusCode filters by exact HTTP status code when > 0. // StatusCode filters by exact HTTP status code when > 0.
@@ -175,9 +177,10 @@ type OpenFlareAccessLogQuery struct {
// OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub). // OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub).
type OpenFlareAccessLogBucketQuery struct { type OpenFlareAccessLogBucketQuery struct {
NodeID string NodeID string
RemoteAddr string RemoteAddr string
Host string Host string
// Hosts follows OpenFlareAccessLogQuery.Hosts exact and wildcard matching.
Hosts []string Hosts []string
Path string Path string
Since time.Time Since time.Time
+2 -2
View File
@@ -25,7 +25,7 @@ func (Zone) TableName() string {
return tableOfZones return tableOfZones
} }
// ZoneDomain OpenFlare Zone 下的明确域名实体。 // ZoneDomain OpenFlare Zone 下的具体或通配符域名实体。
type ZoneDomain struct { type ZoneDomain struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"` ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
ZoneID uint `json:"zone_id" gorm:"not null;index:idx_of_zone_domains_zone_id"` ZoneID uint `json:"zone_id" gorm:"not null;index:idx_of_zone_domains_zone_id"`
@@ -41,7 +41,7 @@ func (ZoneDomain) TableName() string {
return tableOfZoneDomains return tableOfZoneDomains
} }
// ZoneDomainCount is the per-zone explicit domain count for list queries. // ZoneDomainCount 是每个 Zone 的登记域名记录数,通配符按一条记录计数。
type ZoneDomainCount struct { type ZoneDomainCount struct {
ZoneID uint `json:"zone_id" gorm:"column:zone_id"` ZoneID uint `json:"zone_id" gorm:"column:zone_id"`
Count int64 `json:"count" gorm:"column:count"` Count int64 `json:"count" gorm:"column:count"`
@@ -42,12 +42,28 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any)
} }
hosts := normalizeNodeAccessLogHosts(filter.Hosts) hosts := normalizeNodeAccessLogHosts(filter.Hosts)
if len(hosts) > 0 { if len(hosts) > 0 {
hostParts := make([]string, 0, len(hosts))
placeholders := make([]string, 0, len(hosts)) placeholders := make([]string, 0, len(hosts))
var exactHosts []any
for _, host := range hosts { for _, host := range hosts {
if suffix, wildcard := strings.CutPrefix(host, "*."); wildcard && suffix != "" {
// _% 要求非空子域名,并保留 Nginx 通配符可匹配多层子域名的语义。
hostParts = append(hostParts, "(lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')")
args = append(args, "_%."+util.EscapeLike(suffix))
continue
}
placeholders = append(placeholders, "?") placeholders = append(placeholders, "?")
args = append(args, host) exactHosts = append(exactHosts, host)
} }
parts = append(parts, "lowerUTF8(trim(host)) IN ("+strings.Join(placeholders, ", ")+")") if len(exactHosts) > 0 {
hostParts = append(hostParts, "lowerUTF8(trim(host)) IN ("+strings.Join(placeholders, ", ")+")")
args = append(args, exactHosts...)
}
hostClause := strings.Join(hostParts, " OR ")
if len(hostParts) > 1 {
hostClause = "(" + hostClause + ")"
}
parts = append(parts, hostClause)
} else if trimmed := strings.TrimSpace(filter.Host); trimmed != "" { } else if trimmed := strings.TrimSpace(filter.Host); trimmed != "" {
parts = append(parts, "host LIKE ?") parts = append(parts, "host LIKE ?")
args = append(args, util.EscapeLike(trimmed)+"%") args = append(args, util.EscapeLike(trimmed)+"%")
@@ -0,0 +1,132 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package analytics
import (
"context"
"strings"
"testing"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/google/go-cmp/cmp"
)
func TestNodeAccessLogHostsWildcardSQL(t *testing.T) {
since := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
until := since.Add(time.Hour)
tests := []struct {
name string
filter NodeAccessLogFilter
clause string
args []any
}{
{
name: "exact_hosts_remain_exact_and_normalized",
filter: NodeAccessLogFilter{Hosts: []string{" API.Example.com ", "api.example.com", "", "www.example.com"}},
clause: "lowerUTF8(trim(host)) IN (?, ?)",
args: []any{"api.example.com", "www.example.com"},
},
{
name: "wildcard_requires_nonempty_prefix",
filter: NodeAccessLogFilter{Hosts: []string{" *.Example.com ", "*.example.com"}},
clause: "(lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')",
args: []any{"_%.example.com"},
},
{
name: "nested_wildcard",
filter: NodeAccessLogFilter{Hosts: []string{"*.s3.example.com"}},
clause: "(lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')",
args: []any{"_%.s3.example.com"},
},
{
name: "overlapping_scopes_are_one_group_with_other_filters",
filter: NodeAccessLogFilter{
NodeID: " n1 ", Host: "ignored", Hosts: []string{"api.example.com", "*.example.com", "*.s3.example.com"},
StatusCode: 200, Since: since, Until: until,
},
clause: "node_id = ? AND ((lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%') OR (lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%') OR lowerUTF8(trim(host)) IN (?)) AND status_code = ? AND logged_at >= ? AND logged_at < ?",
args: []any{"n1", "_%.example.com", "_%.s3.example.com", "api.example.com", 200, since, until},
},
{
name: "wildcard_suffix_like_metacharacters_are_literal",
filter: NodeAccessLogFilter{Hosts: []string{`*.a%_\'?.example.com`}},
clause: "(lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')",
args: []any{`_%.a\%\_\\'?.example.com`},
},
{
name: "single_host_keeps_prefix_search",
filter: NodeAccessLogFilter{Host: " *.ex%_ ", Hosts: []string{" "}},
clause: "host LIKE ?",
args: []any{`*.ex\%\_%`},
},
{
name: "non_leading_wildcards_are_exact",
filter: NodeAccessLogFilter{Hosts: []string{"api.*.example.com", "*", "*."}},
clause: "lowerUTF8(trim(host)) IN (?, ?, ?)",
args: []any{"api.*.example.com", "*", "*."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clause, args := buildNodeAccessLogFilterClause(tt.filter)
if clause != tt.clause {
t.Errorf("buildNodeAccessLogFilterClause(%+v) SQL = %q, want %q", tt.filter, clause, tt.clause)
}
if diff := cmp.Diff(tt.args, args); diff != "" {
t.Errorf("buildNodeAccessLogFilterClause(%+v) args mismatch (-want +got):\n%s", tt.filter, diff)
}
})
}
}
// wildcardQueryConn captures both single-row and multi-row query entry points.
type wildcardQueryConn struct{ mockConn }
func (m *wildcardQueryConn) QueryRow(_ context.Context, query string, args ...any) driver.Row {
m.queries = append(m.queries, query)
m.queryArgs = append(m.queryArgs, args)
return wildcardCountRow{}
}
type wildcardCountRow struct{ driver.Row }
func (wildcardCountRow) Scan(dest ...any) error {
for _, value := range dest {
*value.(*uint64) = 0
}
return nil
}
// TestWildcardQueryEntryPoints protects host filtering in count and bucket queries.
func TestWildcardQueryEntryPoints(t *testing.T) {
conn := &wildcardQueryConn{}
previous := db.ChConn
db.SetChConnForTest(conn)
t.Cleanup(func() { db.SetChConnForTest(previous) })
ctx := context.Background()
filter := NodeAccessLogFilter{Hosts: []string{"*.Example.com", "api.example.com"}}
if _, _, _, err := CountNodeAccessLogs(ctx, filter); err != nil {
t.Fatal(err)
}
if _, err := BucketAggregatesNodeAccessLogs(ctx, filter, 300); err != nil {
t.Fatal(err)
}
if _, err := CountBucketAggregatesNodeAccessLogs(ctx, filter, 300); err != nil {
t.Fatal(err)
}
if len(conn.queries) != 3 {
t.Fatalf("count/bucket query calls = %d, want 3", len(conn.queries))
}
for i, query := range conn.queries {
if !strings.Contains(query, "WHERE ((lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%') OR lowerUTF8(trim(host)) IN (?))") {
t.Errorf("count/bucket SQL = %q, want grouped wildcard and exact host predicates", query)
}
if diff := cmp.Diff([]any{"_%.example.com", "api.example.com"}, conn.queryArgs[i]); diff != "" {
t.Errorf("count/bucket args mismatch (-want +got):\n%s", diff)
}
}
}
@@ -0,0 +1,135 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package logstore
import (
"context"
"slices"
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics"
"github.com/google/go-cmp/cmp"
)
func TestGormNodeAccessLogWildcardHosts(t *testing.T) {
ResetForTest()
t.Cleanup(ResetForTest)
SetConfigReader(func(context.Context, string) (string, error) { return "", nil })
s := newTestGormStore(t)
ctx := context.Background()
base := time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)
hosts := []string{
"api.example.com", " A.B.Example.COM ", "bucket.s3.example.com", "deep.bucket.s3.example.com",
"example.com", "s3.example.com", "evil-example.com", "api.example.com.evil", ".example.com",
`x.a%_\'?.example.net`, `x.abc\'?.example.net`, ".s3.other.com", "s3.other.com", "a..example.com", ".a.example.com", "a..s3.example.com",
}
rows := make([]analyticsmodel.NodeAccessLog, 0, len(hosts)+3)
for i, host := range hosts {
rows = append(rows, analyticsmodel.NodeAccessLog{
ID: uint64(i + 1), NodeID: "n1", Host: host, LoggedAt: base.Add(time.Duration(i%2)*time.Hour + time.Minute),
RemoteAddr: "1.1.1.1", BytesSent: uint64((i + 1) * 10), StatusCode: 200,
})
}
rows[2].RemoteAddr = "2.2.2.2"
rows[3].RemoteAddr = "" // 空 IP 仍计入请求与字节数,但不计入 UV。
rows = append(rows,
analyticsmodel.NodeAccessLog{ID: 100, NodeID: "other", Host: "api.example.com", LoggedAt: base.Add(time.Minute)},
analyticsmodel.NodeAccessLog{ID: 101, NodeID: "n1", Host: "api.example.com", LoggedAt: base.Add(2 * time.Hour)},
analyticsmodel.NodeAccessLog{ID: 102, NodeID: "n1", Host: "bucket.s3.example.com", LoggedAt: base.Add(-time.Second)},
)
if err := s.BatchInsertNodeAccessLogs(ctx, rows); err != nil {
t.Fatalf("BatchInsertNodeAccessLogs(wildcard fixtures) error = %v, want nil", err)
}
tests := []struct {
name string
hosts []string
host string
wantIDs []uint64
}{
{name: "exact", hosts: []string{" API.EXAMPLE.COM "}, wantIDs: []uint64{1}},
{name: "wildcard_multilevel", hosts: []string{" *.Example.com "}, wantIDs: []uint64{1, 2, 3, 4, 6}},
{name: "nested_wildcard_excludes_suffix_root", hosts: []string{"*.s3.example.com"}, wantIDs: []uint64{3, 4}},
{name: "overlap_does_not_double_count", hosts: []string{"api.example.com", "*.example.com", "*.s3.example.com", "*.EXAMPLE.COM"}, host: "ignored", wantIDs: []uint64{1, 2, 3, 4, 6}},
{name: "exact_root_and_nested_wildcard", hosts: []string{"example.com", "*.s3.example.com"}, wantIDs: []uint64{3, 4, 5}},
{name: "exact_suffix_root", hosts: []string{"example.com"}, wantIDs: []uint64{5}},
{name: "like_metacharacters_are_literal", hosts: []string{`*.a%_\'?.example.net`}, wantIDs: []uint64{10}},
{name: "empty_prefix_is_not_a_subdomain", hosts: []string{"*.s3.other.com"}, wantIDs: []uint64{}},
{name: "single_host_keeps_prefix_search", host: "api.example.com", wantIDs: []uint64{1, 8}},
{name: "single_host_does_not_enable_wildcard", host: "*.example.com", wantIDs: []uint64{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q := model.OpenFlareAccessLogQuery{NodeID: "n1", Hosts: tt.hosts, Host: tt.host, Since: base, Until: base.Add(2 * time.Hour)}
type totals struct {
Requests int64
UniqueIP int64
Bytes int64
}
want := totals{Requests: int64(len(tt.wantIDs))}
ips := make(map[string]struct{})
wantBuckets := make(map[int64]totals)
bucketIPs := make(map[int64]map[string]struct{})
for _, id := range tt.wantIDs {
r := rows[id-1]
want.Bytes += int64(r.BytesSent)
if r.RemoteAddr != "" {
ips[r.RemoteAddr] = struct{}{}
}
epoch := r.LoggedAt.Truncate(time.Hour).Unix()
bucket := wantBuckets[epoch]
bucket.Requests++
bucket.Bytes += int64(r.BytesSent)
if bucketIPs[epoch] == nil {
bucketIPs[epoch] = make(map[string]struct{})
}
if r.RemoteAddr != "" {
bucketIPs[epoch][r.RemoteAddr] = struct{}{}
}
bucket.UniqueIP = int64(len(bucketIPs[epoch]))
wantBuckets[epoch] = bucket
}
want.UniqueIP = int64(len(ips))
total, unique, bytes, err := s.Count(ctx, q)
if err != nil {
t.Fatalf("Count(%+v) error = %v, want nil", q, err)
}
if got := (totals{total, unique, bytes}); got != want {
t.Errorf("Count(%+v) = %+v, want %+v", q, got, want)
}
listed, err := s.List(ctx, q)
if err != nil {
t.Fatalf("List(%+v) error = %v, want nil", q, err)
}
gotIDs := make([]uint64, 0, len(listed))
for _, r := range listed {
gotIDs = append(gotIDs, uint64(r.ID))
}
slices.Sort(gotIDs)
if diff := cmp.Diff(tt.wantIDs, gotIDs); diff != "" {
t.Errorf("List(%+v) IDs mismatch (-want +got):\n%s", q, diff)
}
buckets, err := s.BucketAggregates(ctx, q, 3600)
if err != nil {
t.Fatalf("BucketAggregates(%+v, 3600) error = %v, want nil", q, err)
}
gotBuckets := make(map[int64]totals)
for _, b := range buckets {
gotBuckets[b.BucketEpoch] = totals{b.RequestCount, b.UniqueIPCount, b.BytesSent}
}
if diff := cmp.Diff(wantBuckets, gotBuckets); diff != "" {
t.Errorf("BucketAggregates(%+v, 3600) mismatch (-want +got):\n%s", q, diff)
}
bucketCount, err := s.CountBuckets(ctx, q, 3600)
if err != nil {
t.Fatalf("CountBuckets(%+v, 3600) error = %v, want nil", q, err)
}
if bucketCount != int64(len(wantBuckets)) {
t.Errorf("CountBuckets(%+v, 3600) = %d, want %d", q, bucketCount, len(wantBuckets))
}
})
}
}
+22 -4
View File
@@ -814,7 +814,7 @@ func toNodeAccessLogFilter(query model.OpenFlareAccessLogQuery) analyticsmodel.N
} }
// buildNodeAccessLogFilterParts 拼装节点访问日志过滤条件(node_id 等值、remote_addr/host/path // buildNodeAccessLogFilterParts 拼装节点访问日志过滤条件(node_id 等值、remote_addr/host/path
// 前缀 LIKE、hosts 走 lower(trim(host)) IN、since/until 时间窗,顺序与 applyNodeAccessLogFilter // 前缀 LIKE、hosts 精确域名或通配符后缀、since/until 时间窗,顺序与 applyNodeAccessLogFilter
// 完全一致),返回 WHERE 片段与参数;无任何条件时返回空串与 nil。 // 完全一致),返回 WHERE 片段与参数;无任何条件时返回空串与 nil。
func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string, []any) { func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string, []any) {
var parts []string var parts []string
@@ -829,8 +829,26 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string
} }
hosts := normalizeNodeAccessLogHosts(f.Hosts) hosts := normalizeNodeAccessLogHosts(f.Hosts)
if len(hosts) > 0 { if len(hosts) > 0 {
parts = append(parts, "lower(trim(host)) IN ?") var hostParts []string
args = append(args, hosts) var exactHosts []string
for _, host := range hosts {
if suffix, wildcard := strings.CutPrefix(host, "*."); wildcard && suffix != "" {
// 与 CH 一致:非空子域名,可跨多层,但不匹配 suffix 根自身。
hostParts = append(hostParts, `(lower(trim(host)) LIKE ? ESCAPE '\' AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')`)
args = append(args, "_%."+util.EscapeLike(suffix))
continue
}
exactHosts = append(exactHosts, host)
}
if len(exactHosts) > 0 {
hostParts = append(hostParts, "lower(trim(host)) IN ?")
args = append(args, exactHosts)
}
hostClause := strings.Join(hostParts, " OR ")
if len(hostParts) > 1 {
hostClause = "(" + hostClause + ")"
}
parts = append(parts, hostClause)
} else if host := strings.TrimSpace(f.Host); host != "" { } else if host := strings.TrimSpace(f.Host); host != "" {
parts = append(parts, `host LIKE ? ESCAPE '\'`) parts = append(parts, `host LIKE ? ESCAPE '\'`)
args = append(args, util.EscapeLike(host)+"%") args = append(args, util.EscapeLike(host)+"%")
@@ -858,7 +876,7 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string
} }
// applyNodeAccessLogFilter 对齐 CH 过滤语义(node_access_log_filter.go): // applyNodeAccessLogFilter 对齐 CH 过滤语义(node_access_log_filter.go):
// node_id trim 后等值;remote_addr/host/path 前缀 LIKE;hosts 走 lower(trim(host)) IN(参数已归一化); // node_id trim 后等值;remote_addr/host/path 前缀 LIKE;hosts 精确域名或通配符后缀(参数已归一化);
// since 闭区间 >=;until 开区间 <。 // since 闭区间 >=;until 开区间 <。
func applyNodeAccessLogFilter(q *gorm.DB, f analyticsmodel.NodeAccessLogFilter) *gorm.DB { func applyNodeAccessLogFilter(q *gorm.DB, f analyticsmodel.NodeAccessLogFilter) *gorm.DB {
cond, args := buildNodeAccessLogFilterParts(f) cond, args := buildNodeAccessLogFilterParts(f)
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package openresty
import (
"regexp"
"strings"
"testing"
)
func TestWildcardCertificateCoverage(t *testing.T) {
tests := []struct {
certDomain string
domain string
wantErr bool
}{
{certDomain: "*.s3.example.com", domain: "*.s3.example.com"},
{certDomain: "*.s3.example.com", domain: "bucket.s3.example.com"},
{certDomain: "*.example.com", domain: "*.s3.example.com", wantErr: true},
{certDomain: "bucket.s3.example.com", domain: "*.s3.example.com", wantErr: true},
{certDomain: "s3.example.com", domain: "*.s3.example.com", wantErr: true},
{certDomain: "*.s3.example.com", domain: "s3.example.com", wantErr: true},
{certDomain: "*.s3.example.com", domain: "nested.bucket.s3.example.com", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.certDomain+"_for_"+tt.domain, func(t *testing.T) {
err := validateCertificateCoverage(testCertificatePEMForDomain(t, tt.certDomain), []string{tt.domain})
if (err != nil) != tt.wantErr {
t.Errorf("validateCertificateCoverage(cert=%q, domain=%q) error = %v, want error=%t", tt.certDomain, tt.domain, err, tt.wantErr)
}
})
}
}
func TestRenderWildcardAndExactRoutes(t *testing.T) {
for _, https := range []bool{false, true} {
t.Run(map[bool]string{false: "HTTP", true: "HTTPS"}[https], func(t *testing.T) {
doc := Document{Routes: []Route{
{ID: 1, SiteName: "wildcard", Domains: []string{"*.s3.example.com"}, OriginURL: "http://wildcard-origin:8080", EnableHTTPS: https, RedirectHTTP: https, DomainCertIDs: []uint{1}},
{ID: 2, SiteName: "exact", Domains: []string{"special.s3.example.com"}, OriginURL: "http://exact-origin:8080", EnableHTTPS: https, RedirectHTTP: https, DomainCertIDs: []uint{1}},
}}
files := []SupportFile{{Path: "1.crt", Content: testCertificatePEMForDomain(t, "*.s3.example.com")}}
got, err := RenderRouteConfig(doc, files)
if err != nil {
t.Fatalf("RenderRouteConfig(wildcard+exact, HTTPS=%t) error = %v", https, err)
}
for _, tt := range []struct{ domain, upstream, origin string }{
{domain: "*.s3.example.com", upstream: "backend_wildcard_1", origin: "wildcard-origin:8080"},
{domain: "special.s3.example.com", upstream: "backend_exact_2", origin: "exact-origin:8080"},
} {
upstreamPattern := `(?ms)^upstream ` + regexp.QuoteMeta(tt.upstream) + ` \{.*?^\}`
upstream := regexp.MustCompile(upstreamPattern).FindString(got)
if !strings.Contains(upstream, "server "+tt.origin+" ") {
t.Errorf("upstream %s = %q, want origin %s", tt.upstream, upstream, tt.origin)
}
proxyCount, redirectCount := 0, 0
for _, block := range regexp.MustCompile(`(?ms)^server \{.*?^\}`).FindAllString(got, -1) {
if !strings.Contains(block, "server_name "+tt.domain+";") {
continue
}
if https && strings.Contains(block, "listen 80;") {
redirectCount++
if !strings.Contains(block, "return 301 https://$host$request_uri;") {
t.Errorf("redirect for %s = %q, want actual request host", tt.domain, block)
}
} else {
proxyCount++
if !strings.Contains(block, "proxy_pass http://"+tt.upstream+";") {
t.Errorf("proxy for %s = %q, want upstream %s", tt.domain, block, tt.upstream)
}
}
}
wantRedirects := 0
if https {
wantRedirects = 1
}
if proxyCount != 1 || redirectCount != wantRedirects {
t.Errorf("server blocks for %s: proxy=%d redirect=%d, want proxy=1 redirect=%d", tt.domain, proxyCount, redirectCount, wantRedirects)
}
}
})
}
}
// TestImportedIDNRouteRendering covers the canonical names produced by legacy import.
func TestImportedIDNRouteRendering(t *testing.T) {
domain := "api.xn--bcher-kva.de"
for _, https := range []bool{false, true} {
doc := Document{Routes: []Route{{ID: 1, SiteName: "idn", Domains: []string{domain}, OriginURL: "http://origin:8080", EnableHTTPS: https, DomainCertIDs: []uint{1}}}}
files := []SupportFile{{Path: "1.crt", Content: testCertificatePEMForDomain(t, domain)}}
got, err := RenderRouteConfig(doc, files)
if err != nil {
t.Fatalf("RenderRouteConfig(imported IDN, HTTPS=%t): %v", https, err)
}
if !strings.Contains(got, "server_name "+domain+";") {
t.Errorf("RenderRouteConfig(imported IDN, HTTPS=%t) missing canonical server name", https)
}
}
}
+4 -3
View File
@@ -27,9 +27,10 @@ func init() {
// Config 链路追踪配置 // Config 链路追踪配置
type Config struct { type Config struct {
AppName string AppName string
SamplingRate float64 SamplingRate float64
TracerName string TracerName string
ExportProtocol string
} }
// Init 初始化 Tracer Provider 并关联全局 Tracer 实例 // Init 初始化 Tracer Provider 并关联全局 Tracer 实例
+19 -1
View File
@@ -6,14 +6,18 @@ package trace
import ( import (
"context" "context"
"fmt"
"os" "os"
"strings"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/resource" "go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace" sdktrace "go.opentelemetry.io/otel/sdk/trace"
) )
// newTracerProvider combines SDK and application resources with a batched exporter.
func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) { func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) {
// 获取主机名和容器信息 // 获取主机名和容器信息
hostname, err := os.Hostname() hostname, err := os.Hostname()
@@ -37,7 +41,7 @@ func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) {
} }
// 初始化 Exporter // 初始化 Exporter
traceExporter, err := otlptracegrpc.New(context.Background()) traceExporter, err := newTraceExporter(context.Background(), cfg.ExportProtocol)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -50,3 +54,17 @@ func newTracerProvider(cfg Config) (*sdktrace.TracerProvider, error) {
) )
return tracerProvider, nil return tracerProvider, nil
} }
// newTraceExporter selects the OTLP wire transport without changing existing gRPC defaults.
// The SDK exporters read standard OTEL_EXPORTER_OTLP_* endpoint and header settings.
func newTraceExporter(ctx context.Context, protocol string) (sdktrace.SpanExporter, error) {
protocol = strings.ToLower(strings.TrimSpace(protocol))
switch protocol {
case "", "grpc":
return otlptracegrpc.New(ctx)
case "http/protobuf":
return otlptracehttp.New(ctx)
default:
return nil, fmt.Errorf("unsupported OTLP trace protocol %q", protocol)
}
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package trace
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
collecttrace "go.opentelemetry.io/proto/otlp/collector/trace/v1"
"google.golang.org/protobuf/proto"
)
// TestOTLPHTTPTraceExportHeadersAndResources verifies wire payloads against a local collector.
func TestOTLPHTTPTraceExportHeadersAndResources(t *testing.T) {
type received struct {
headers http.Header
data []byte
path string
method string
}
requests := make(chan received, 1)
responseBody := protoBytes(t, &collecttrace.ExportTraceServiceResponse{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
requests <- received{headers: r.Header.Clone(), data: body, path: r.URL.Path, method: r.Method}
w.Header().Set("Content-Type", "application/x-protobuf")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(responseBody)
}))
defer server.Close()
t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "")
t.Setenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "")
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", server.URL)
t.Setenv("OTEL_EXPORTER_OTLP_HEADERS", "X-API-Key=test-key,X-Client-Name=test%20client")
t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "service.namespace=example,deployment.environment.name=test")
tests := []struct {
name string
env map[string]string
wantPath string
wantHeaders map[string]string
}{
{
name: "base_endpoint",
wantPath: "/v1/traces",
wantHeaders: map[string]string{"X-API-Key": "test-key", "X-Client-Name": "test client"},
},
{
name: "base_endpoint_path",
env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": server.URL + "/collector"},
wantPath: "/collector/v1/traces",
wantHeaders: map[string]string{"X-API-Key": "test-key", "X-Client-Name": "test client"},
},
{
name: "trace_specific_overrides",
env: map[string]string{
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": server.URL + "/custom/ingest",
"OTEL_EXPORTER_OTLP_TRACES_HEADERS": "X-API-Key=trace-key,X-Client-Name=trace%20client",
},
wantPath: "/custom/ingest",
wantHeaders: map[string]string{"X-API-Key": "trace-key", "X-Client-Name": "trace client"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for key, value := range tt.env {
t.Setenv(key, value)
}
provider, err := newTracerProvider(Config{AppName: "openflare-control-plane", SamplingRate: 1, ExportProtocol: "http/protobuf"})
if err != nil {
t.Fatal(err)
}
defer func() { _ = provider.Shutdown(context.Background()) }()
_, span := provider.Tracer("test").Start(context.Background(), "canary")
span.End()
if err := provider.ForceFlush(context.Background()); err != nil {
t.Fatal(err)
}
select {
case req := <-requests:
for key, want := range tt.wantHeaders {
if got := req.headers.Get(key); got != want {
t.Errorf("OTLP request header %q = %q, want %q", key, got, want)
}
}
if req.method != "POST" || req.path != tt.wantPath {
t.Errorf("OTLP request = %s %s, want POST %s", req.method, req.path, tt.wantPath)
}
var payload collecttrace.ExportTraceServiceRequest
if err := proto.Unmarshal(req.data, &payload); err != nil {
t.Fatal(err)
}
if len(payload.ResourceSpans) == 0 {
t.Fatal("missing resource spans")
}
attrs := make(map[string]string)
for _, a := range payload.ResourceSpans[0].Resource.Attributes {
attrs[a.Key] = a.Value.GetStringValue()
}
for k, v := range map[string]string{"service.name": "openflare-control-plane", "service.namespace": "example", "deployment.environment.name": "test"} {
if attrs[k] != v {
t.Errorf("resource attribute %s mismatch: got %q", k, attrs[k])
}
}
default:
t.Fatal("OTLP HTTP request was not received")
}
})
}
}
// protoBytes encodes a collector response for the HTTP test server.
func protoBytes(t *testing.T, v proto.Message) []byte {
t.Helper()
data, err := proto.Marshal(v)
if err != nil {
t.Fatal(err)
}
return data
}
// TestUnknownOTLPProtocolFails rejects unsupported wire formats during initialization.
func TestUnknownOTLPProtocolFails(t *testing.T) {
_, err := newTraceExporter(context.Background(), "http/json")
if err == nil || !strings.Contains(err.Error(), "unsupported OTLP") {
t.Fatalf("expected protocol error, got %v", err)
}
}
// TestDefaultOTLPProtocolGRPC keeps empty and explicit gRPC configuration working.
func TestDefaultOTLPProtocolGRPC(t *testing.T) {
for _, protocol := range []string{"", "grpc"} {
exporter, err := newTraceExporter(context.Background(), protocol)
if err != nil {
t.Fatalf("newTraceExporter(%q): %v", protocol, err)
}
if err := exporter.Shutdown(context.Background()); err != nil {
t.Errorf("exporter.Shutdown(%q): %v", protocol, err)
}
}
}