mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 217a71662f | |||
| 9a51a68206 | |||
| 8d8b3e1ea8 | |||
| acd63ff410 | |||
| b281b65e78 | |||
| e0ba758afa |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
### ✨ 新功能
|
### ✨ 新功能
|
||||||
|
|||||||
@@ -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/)处理,不依赖特定部署平台或观测后端。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 首次登录
|
## 首次登录
|
||||||
|
|||||||
@@ -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 配置。
|
||||||
|
|
||||||
### 触发
|
### 触发
|
||||||
|
|
||||||
| 触发 | 行为 |
|
| 触发 | 行为 |
|
||||||
|
|||||||
@@ -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 绑定键等)。
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 标签的输入也会被拒绝。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 第四步:发布并生效配置
|
## 第四步:发布并生效配置
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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": "批量删除",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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('*');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -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;
|
||||||
+6
@@ -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;
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 实例
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user