mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
feat(domain): 支持通配符域名及关联功能适配 (#63)
* feat(domain): 支持通配符域名及关联功能适配 * fix(domain): preserve legacy Unicode imports and address review findings * fix(domain): canonicalize legacy IDNs and exclude empty host labels * fix(domain): normalize equivalent Unicode IDN spellings --------- Co-authored-by: Ryan <63696351+Rain-kl@users.noreply.github.com> Co-authored-by: ryan <ryan@arctel.net>
This commit is contained in:
@@ -17,8 +17,15 @@ sidebar: false
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 🛠 修复
|
||||||
|
|
||||||
|
- 修复升级导入历史 Unicode 域名时被新域名校验拒绝、导致服务无法启动的问题,统一保存为 Punycode 并保留证书和站点绑定,确保路由与证书校验使用一致的域名身份。
|
||||||
|
- 修复通配符流量统计会计入含空标签的非法 Host 的问题,继续支持合法的多级子域名。
|
||||||
|
|
||||||
### ✨ 新功能
|
### ✨ 新功能
|
||||||
|
|
||||||
|
- 域名列表与快捷新增支持 `*.example.com`、`*.s3.example.com` 等通配符域名,可绑定站点规则并随配置版本下发至 CDN Agent;Cloudflare 分组可为其同步 A、AAAA 或 CNAME 记录。
|
||||||
|
- Zone 流量统计支持聚合通配符匹配的实际子域名,重叠域名不会重复计数;Uptime Kuma 优先使用具体域名,只有通配符时跳过监控创建或更新并记录原因。
|
||||||
- OpenTelemetry 链路追踪新增 OTLP/HTTP protobuf 导出,通过标准环境变量配置接收端地址和自定义请求头,协议由统一启动配置解析并支持 `otel.export_protocol`,保留原有 OTLP/gRPC 默认模式;未支持的协议在启动时明确报错。
|
- OpenTelemetry 链路追踪新增 OTLP/HTTP protobuf 导出,通过标准环境变量配置接收端地址和自定义请求头,协议由统一启动配置解析并支持 `otel.export_protocol`,保留原有 OTLP/gRPC 默认模式;未支持的协议在启动时明确报错。
|
||||||
- Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。
|
- Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。
|
||||||
|
|
||||||
|
|||||||
@@ -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 还需匹配的证书。
|
||||||
|
|
||||||
## 与现有能力的关系
|
## 与现有能力的关系
|
||||||
|
|
||||||
| 现有能力 | 关系 |
|
| 现有能力 | 关系 |
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -12195,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
|
||||||
@@ -12273,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
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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": "不绑定证书"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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],
|
||||||
|
['www.api.example.com', 'www.api.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);
|
||||||
});
|
});
|
||||||
expect(resolveZoneDomainInput('@', 'example.com')).toEqual({
|
|
||||||
domain: 'example.com',
|
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('*');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
return "http://" + domain, nil
|
if route.EnableHTTPS {
|
||||||
|
return "https://" + domain.Domain, nil
|
||||||
|
}
|
||||||
|
return "http://" + domain.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ 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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -178,6 +180,7 @@ 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 {
|
||||||
placeholders = append(placeholders, "?")
|
if suffix, wildcard := strings.CutPrefix(host, "*."); wildcard && suffix != "" {
|
||||||
args = append(args, host)
|
// _% 要求非空子域名,并保留 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
|
||||||
}
|
}
|
||||||
parts = append(parts, "lowerUTF8(trim(host)) IN ("+strings.Join(placeholders, ", ")+")")
|
placeholders = append(placeholders, "?")
|
||||||
|
exactHosts = append(exactHosts, host)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user