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:
Deqiying
2026-11-09 10:31:03 +08:00
committed by GitHub
parent 8d8b3e1ea8
commit 9a51a68206
37 changed files with 1614 additions and 113 deletions
+7
View File
@@ -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 秒行为。
+12 -5
View File
@@ -2,9 +2,9 @@
## 目标 ## 目标
通过 Cloudflare API 将 OpenFlare 中的 **ZoneDomain(明确 FQDN)** 快速指向边缘节点 IP,替代在 CF 控制台手工改 A 记录。用户以 **指向分组** 组织域名:每组配置主节点与备用节点、默认橙云策略;成员可单独覆盖橙云。系统以库表为期望状态,幂等同步远端 DNS。 通过 Cloudflare API 将 OpenFlare 中的 **ZoneDomain(具体或通配符 FQDN)** 快速指向边缘节点 IP 或自定义 DNS 目标。用户以 **指向分组** 组织域名:节点模式配置主节点与备用节点,自定义模式配置 A/AAAA/CNAME 的记录内容;成员可单独覆盖橙云。系统以库表为期望状态,幂等同步远端 DNS。
本模块是 **可选对接能力**,不把 Zone 本身变成权威 DNS 控制面。Zone 仍只负责根域边界、域名、证书与反代关联;DNS A 记录的创建/更新/删除由本模块驱动 Cloudflare。 本模块是 **可选对接能力**,不把 Zone 本身变成权威 DNS 控制面。Zone 仍只负责根域边界、域名、证书与反代关联;DNS A/AAAA/CNAME 记录的创建/更新/删除由本模块驱动 Cloudflare。
## 范围与分期 ## 范围与分期
@@ -14,7 +14,7 @@
* 连接配置:从现有 DNS 账号导入 **或** 模块内独立录入(混合来源),加密存储 * 连接配置:从现有 DNS 账号导入 **或** 模块内独立录入(混合来源),加密存储
* 指向分组 CRUD:主节点、备用节点(预留)、分组默认橙云 * 指向分组 CRUD:主节点、备用节点(预留)、分组默认橙云
* 成员管理:以 `zone_domain_id` 为粒度加入/移出;成员级橙云 * 成员管理:以 `zone_domain_id` 为粒度加入/移出;成员级橙云
* 同步:将每个成员写成 Cloudflare 上 **单条 A 记录** → 当前生效节点 IPv4 * 同步:节点模式写入 **单条 A 记录** → 当前生效节点 IPv4;自定义模式按分组设置写入 A、AAAA 或 CNAME 记录。
* 触发:手动同步、加入成员、改节点/橙云、节点 IP 变更入队 * 触发:手动同步、加入成员、改节点/橙云、节点 IP 变更入队
* 异步任务批量同步;成员同步状态与可读错误 * 异步任务批量同步;成员同步状态与可读错误
@@ -26,11 +26,18 @@
### 明确不做(更远或永久) ### 明确不做(更远或永久)
* 多 Cloudflare 账号并行(全局一份连接配置) * 多 Cloudflare 账号并行(全局一份连接配置)
* AAAA / 多 A 负载 / CNAME 到节点主机名 * 节点模式的 IPv6 地址跟随 / 多 A 负载 / CNAME 到节点主机名(自定义 AAAA/CNAME 不受此限制)
* 管理 MX/TXT/Page Rules 等非本模块 A 记录 * 管理 MX/TXT/Page Rules 等其他记录或规则
* 非 Cloudflare DNS 厂商 * 非 Cloudflare DNS 厂商
* 将 DNS 记录管理并入 Zone 核心模型 * 将 DNS 记录管理并入 Zone 核心模型
### 通配符域名
* 先在 Zone 域名列表登记 `*.example.com` 或 `*.s3.example.com`,再在分组成员选择器中添加;可用域名、成员归属与同步状态沿用具体域名的规则。
* 节点模式创建 A 记录;需要 AAAA 或 CNAME 时选择自定义模式,分别填写 IPv6 地址或具体目标主机名。
* Cloudflare Zone 查询仍使用注册根域(例如 `example.com`);DNS 记录的 `name` 保留完整通配符名称。创建、更新、查询与删除共用原有同步路径,不能把 `*.` 去掉后作为记录名。
* DNS-only / 橙云选项沿用分组默认值及成员覆盖值。DNS 同步成功不代表代理配置已生效,仍需绑定站点路由并发布激活配置;HTTPS 还需匹配的证书。
## 与现有能力的关系 ## 与现有能力的关系
| 现有能力 | 关系 | | 现有能力 | 关系 |
+13 -3
View File
@@ -10,7 +10,7 @@
* Zone 根域使用 Public Suffix List 解析,例如 `api.example.co.uk` 归属 `example.co.uk`。 * Zone 根域使用 Public Suffix List 解析,例如 `api.example.co.uk` 归属 `example.co.uk`。
* URL 使用 ID:列表为 `/websites`,详情为 `/websites/:zoneId`;不使用域名作为 URL 参数。 * URL 使用 ID:列表为 `/websites`,详情为 `/websites/:zoneId`;不使用域名作为 URL 参数。
* Zone 域名必须是明确的 FQDN,禁止录入 `*.example.com`。TLS 证书可仍含通配符 SAN,并用于覆盖明确的 Zone 域名。 * Zone 根域不允许通配符;Zone 域名支持具体 FQDN 或最左侧完整标签为 `*` 的通配符 FQDN,例如 `*.example.com`、`*.s3.example.com`。后端去掉 `*.` 后校验注册根域归属,并拒绝非法 DNS 标签、空白与配置分隔符。
* 一个 Zone 域名至多关联一条反代路由;一条反代路由可关联多个 Zone 域名,因而可跨 Zone 共享同一套上游、缓存、限流、WAF 与 Pages 配置。 * 一个 Zone 域名至多关联一条反代路由;一条反代路由可关联多个 Zone 域名,因而可跨 Zone 共享同一套上游、缓存、限流、WAF 与 Pages 配置。
* Zone 模型本身不新增 DNS 记录、边缘函数、预览子域或租户隔离能力。对外 DNS A 记录的创建/更新由独立的 Cloudflare 指向模块负责,且不改变 Zone / ZoneDomain 表职责。 * Zone 模型本身不新增 DNS 记录、边缘函数、预览子域或租户隔离能力。对外 DNS A 记录的创建/更新由独立的 Cloudflare 指向模块负责,且不改变 Zone / ZoneDomain 表职责。
@@ -43,7 +43,7 @@ erDiagram
### `of_zone_domains` ### `of_zone_domains`
保存 `zone_id`、明确 `domain`、可空的 `proxy_route_id`、可空的 `cert_id` 及时间戳。`domain` 全局唯一;所有关系字段建立索引但不建立物理外键。`proxy_route_id` 允许为空,以承接已准备证书但尚未配置反代的历史域名。 保存 `zone_id`、具体或通配符 `domain`、可空的 `proxy_route_id`、可空的 `cert_id` 及时间戳。`domain` 全局唯一;所有关系字段建立索引但不建立物理外键。`proxy_route_id` 允许为空,以承接已准备证书但尚未配置反代的历史域名。通配符作为一条普通域名记录存储,不展开为子域名列表,不需要额外列或迁移。
`of_proxy_routes` 逐步移除 `domain`、`domains`、`cert_id`、`cert_ids` 与 `domain_cert_ids` 等域名/证书冗余列。路由不得再指定任何 TLS 证书;路由名称 `site_name` 成为稳定的人类可读标识,编译器从关联的 Zone 域名读取 `server_name` 与其 `cert_id`。这使每个明确域名的证书只有一个来源。 `of_proxy_routes` 逐步移除 `domain`、`domains`、`cert_id`、`cert_ids` 与 `domain_cert_ids` 等域名/证书冗余列。路由不得再指定任何 TLS 证书;路由名称 `site_name` 成为稳定的人类可读标识,编译器从关联的 Zone 域名读取 `server_name` 与其 `cert_id`。这使每个明确域名的证书只有一个来源。
@@ -70,13 +70,21 @@ WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚
详情页包含: 详情页包含:
* 概览:域名、路由和有效证书统计;域名—路由—证书摘要;路由级 WAF 与 Pages 摘要。 * 概览:域名、路由和有效证书统计;域名—路由—证书摘要;路由级 WAF 与 Pages 摘要。
* 域名:明确 FQDN 的列表、证书选择和关联路由;不显示或接受通配符域名。 * 域名:具体或通配符 FQDN 的列表、证书选择和关联路由;快捷新增支持 `*` 展开为 `*.当前根域`,多级泛域名须输入完整名称。
* 路由:筛选到当前 Zone 的路由并链接到既有路由详情。 * 路由:筛选到当前 Zone 的路由并链接到既有路由详情。
* 证书:当前 Zone 域名实际引用的证书。 * 证书:当前 Zone 域名实际引用的证书。
* 设置:Zone 备注和受保护的删除操作。 * 设置:Zone 备注和受保护的删除操作。
新增路由时从 Zone 域名中选择;用户也可以先在 Zone 中登记域名,再绑定路由。全局反代路由入口保留,但改用同一套 Zone 域名选择器。 新增路由时从 Zone 域名中选择;用户也可以先在 Zone 中登记域名,再绑定路由。全局反代路由入口保留,但改用同一套 Zone 域名选择器。
### 通配符运行语义
* 配置快照保留 `*.suffix` 原文;绑定路由并发布激活版本后,Agent 使用共享渲染器生成 `server_name *.suffix;`。仅登记域名而未绑定路由不会生成代理规则。
* 路由匹配沿用 OpenResty/Nginx 的规则:具体域名优先于通配符,多个通配符中更长的匹配后缀优先;`*.example.com` 不包含 `example.com` 本身,但可匹配多层子域名。
* TLS 覆盖范围与 HTTP 路由匹配不同:`*.s3.example.com` 站点必须绑定含相应通配符 SAN 的证书;它不能用仅覆盖某个具体子域名的证书替代。单层通配符证书不覆盖根自身或更深层子域名,发布时保留证书覆盖校验。
* Zone 流量统计按实际请求 Host 匹配具体域名或非空通配符后缀。ClickHouse、PostgreSQL 与 SQLite 使用相同的匹配约定,重叠的具体域名/通配符条件不会重复计数;域名数量仍表示登记记录数。
* 通配符不可直接作为访问或探测 URL。Uptime Kuma 同步优先选择路由绑定的具体域名;只有通配符时跳过创建/更新并记录原因,保留已有监控。
## 数据迁移 ## 数据迁移
本次改造分两个发布阶段,以免 SQL 用错误的“末两段域名”规则处理多级公共后缀。操作细则见 [Zone 域名迁移与发布验收](../guide/zone-domain-migration.md)。 本次改造分两个发布阶段,以免 SQL 用错误的“末两段域名”规则处理多级公共后缀。操作细则见 [Zone 域名迁移与发布验收](../guide/zone-domain-migration.md)。
@@ -86,6 +94,8 @@ WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚
3. **代码切换**:控制面 API、配置快照、渲染、前端均以 Zone 域名为唯一来源;路由写入仅使用 `zone_domain_ids`。 3. **代码切换**:控制面 API、配置快照、渲染、前端均以 Zone 域名为唯一来源;路由写入仅使用 `zone_domain_ids`。
4. **第二阶段清理**:Goose SQL `202607130001_drop_legacy_route_domain_columns` 删除 `of_managed_domains` 与 `of_proxy_routes` 冗余列。Down 仅恢复开发库空结构,不回填历史数据。 4. **第二阶段清理**:Goose SQL `202607130001_drop_legacy_route_domain_columns` 删除 `of_managed_domains` 与 `of_proxy_routes` 冗余列。Down 仅恢复开发库空结构,不回填历史数据。
历史数据升级导入时,Unicode 域名经 IDNA 转换并保存为 ASCII(Punycode)形式,以统一根域归属、全局唯一性、HTTP 路由匹配及 HTTPS 证书校验;原记录的证书和站点绑定保持不变。新增域名仍须符合 ASCII DNS 标签规则。
### 运行时模型边界 ### 运行时模型边界
* 持久化:域名与证书只存在于 `of_zone_domains`;`of_proxy_routes` 仅保存路由策略(上游、缓存、限流、WAF 绑定键等)。 * 持久化:域名与证书只存在于 `of_zone_domains`;`of_proxy_routes` 仅保存路由策略(上游、缓存、限流、WAF 绑定键等)。
+2
View File
@@ -12917,6 +12917,7 @@ const docTemplate = `{
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -13044,6 +13045,7 @@ const docTemplate = `{
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
+15 -3
View File
@@ -38,16 +38,28 @@
证书和源站就绪后,即可创建核心网站代理路由: 证书和源站就绪后,即可创建核心网站代理路由:
1. 进入左侧导航 **「网站管理」->「域名列表」**,点击 **「新增 Zone」**: 1. 进入左侧导航 **「网站管理」->「域名列表」**,点击 **「新增 Zone」**,登记注册根域(如 `example.com`,不可填写 `*.example.com`)。进入该 Zone 的域名列表添加具体域名或通配符域名:
* **域名**:输入该站点绑定的域名。 * **域名**:输入 `api`、`@`、`*` 或完整名称(如 `*.s3.example.com`)。`*` 展开为 `*.example.com`,不支持 `*.s3` 简写。
* **绑定证书**:选择第一步准备或申请好的证书。 * **绑定证书**:选择第一步准备或申请好的证书。
2. 配置请求路由规则:进入 **「规则管理」** 页面,点击 **「新建规则」** 或编辑已有规则: 2. 配置请求路由规则:进入 **「规则管理」** 页面,点击 **「新建规则」** 或编辑已有规则:
* **规则名称**:输入规则的唯一简易标识(如 `app-portal-route`)。 * **规则名称**:输入规则的唯一简易标识(如 `app-portal-route`)。
* **域名匹配**:填入对应的域名(支持通配符或精确域名,需与上面登记的域名一致)。 * **域名匹配**:从已登记域名中选择具体或通配符域名,也可通过「快捷新增域名」创建并绑定。
* 在下方 **「反向代理」** 选项卡下,选择 **回源方式** 为「直连上游」。 * 在下方 **「反向代理」** 选项卡下,选择 **回源方式** 为「直连上游」。
* **源站选择**:从下拉框中选择第二步创建的源站;或者选择手动输入并填入 `http://10.0.0.20:9000`。 * **源站选择**:从下拉框中选择第二步创建的源站;或者选择手动输入并填入 `http://10.0.0.20:9000`。
3. 点击保存创建配置。 3. 点击保存创建配置。
### 通配符域名示例
要让 `bucket.s3.example.com` 等域名共用站点规则:
1. 在 `example.com` Zone 下添加 `*.s3.example.com` 并绑定路由;不需要逐个登记 bucket 域名。
2. 使用 HTTPS 时绑定 SAN 包含 `*.s3.example.com` 的证书。`*.example.com` 证书不能替代它;`*.s3.example.com` 证书也不覆盖 `s3.example.com` 或 `a.bucket.s3.example.com`。
3. 在 Cloudflare 分组中选择该通配符域名:节点模式跟随节点 IPv4 写 A 记录;自定义模式可写 A、AAAA 或 CNAME。DNS 同步与站点配置发布是两件事,须分别确认成功。
4. 按下一节发布并激活版本,Agent 会收到并渲染 `server_name *.s3.example.com;`。通配符沿用 Nginx 后缀匹配,可匹配多层子域名但不含后缀根自身;具体域名路由优先,重叠通配符以更长后缀优先。TLS 仍受上一条的单层证书覆盖限制。
5. 使用具体主机名访问验证,例如 `https://bucket.s3.example.com`,不要直接打开带 `*` 的地址。Zone 统计会聚合命中的实际 Host;Uptime Kuma 则需要至少绑定一个具体域名,否则跳过该路由的监控创建/更新并记录原因。
不允许 `foo*.example.com`、`a.*.example.com` 或多个 `*`;带协议、路径、端口、域名内部空白及非法 DNS 标签的输入也会被拒绝。
--- ---
## 第四步:发布并生效配置 ## 第四步:发布并生效配置
+2
View File
@@ -12910,6 +12910,7 @@
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -13037,6 +13038,7 @@
"SessionCookie": [] "SessionCookie": []
} }
], ],
"description": "支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
+2
View File
@@ -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' };
+5 -5
View File
@@ -79,21 +79,21 @@
"expiresAt": "Expires", "expiresAt": "Expires",
"selectZone": "Select a Zone first", "selectZone": "Select a Zone first",
"enterDomain": "Enter a domain", "enterDomain": "Enter a domain",
"noWildcard": "Zone domains do not support wildcards", "noWildcard": "A wildcard must be a single * in the leftmost label, such as *.example.com",
"invalidFormat": "Invalid domain format", "invalidFormat": "Invalid domain format",
"invalidSubdomain": "Invalid subdomain name format", "invalidSubdomain": "Invalid subdomain name format",
"mustBelongToZone": "The domain must belong to Zone {root}", "mustBelongToZone": "The domain must belong to Zone {root}",
"domainAdded": "Domain added", "domainAdded": "Domain added",
"addFailed": "Add failed", "addFailed": "Add failed",
"quickCreateTitle": "Quick add domain", "quickCreateTitle": "Quick add domain",
"quickCreateDesc": "After selecting a Zone, enter a subdomain name (such as api), @ (the root), or a full FQDN.", "quickCreateDesc": "Enter a subdomain (api), @ (the root), * (wildcard under the root), or a full FQDN. For nested wildcards, enter the full name, such as *.s3.example.com; *.s3 shorthand is not supported.",
"selectRegisteredRoot": "Select registered root domain", "selectRegisteredRoot": "Select registered root domain",
"zoneLabel": "Zone:", "zoneLabel": "Zone:",
"domain": "Domain", "domain": "Domain",
"domainPlaceholderWithZone": "api or @ or api.{domain}", "domainPlaceholderWithZone": "api / @ / * / *.s3.{domain}",
"domainPlaceholder": "api / @ / full domain", "domainPlaceholder": "api / @ / * / full domain",
"willCreate": "Will create:", "willCreate": "Will create:",
"domainExample": "Example: enter <api>api</api> → <fqdn>api.zone.com</fqdn>; <apex>@</apex> → the root itself", "domainExample": "Example: <api>api</api> → <fqdn>api.zone.com</fqdn>; <apex>@</apex> → the root; * → *.zone.com. A wildcard cannot be opened directly; visit a concrete matching hostname.",
"certOptional": "Certificate (optional)", "certOptional": "Certificate (optional)",
"noCert": "No certificate" "noCert": "No certificate"
}, },
+1 -1
View File
@@ -79,7 +79,7 @@
"expiresAt": "Срок действия истекает", "expiresAt": "Срок действия истекает",
"selectZone": "Сначала выберите зону", "selectZone": "Сначала выберите зону",
"enterDomain": "Введите домен", "enterDomain": "Введите домен",
"noWildcard": "Домены зон не поддерживают подстановочные знаки", "noWildcard": "Допустим только один символ * как крайняя левая метка, например *.example.com",
"invalidFormat": "Неверный формат домена", "invalidFormat": "Неверный формат домена",
"invalidSubdomain": "Неверный формат имени субдомена", "invalidSubdomain": "Неверный формат имени субдомена",
"mustBelongToZone": "Домен должен принадлежать зоне {root}", "mustBelongToZone": "Домен должен принадлежать зоне {root}",
@@ -79,21 +79,21 @@
"expiresAt": "到期时间", "expiresAt": "到期时间",
"selectZone": "请先选择 Zone", "selectZone": "请先选择 Zone",
"enterDomain": "请输入域名", "enterDomain": "请输入域名",
"noWildcard": "Zone 域名不支持通配符", "noWildcard": "通配符只能是最左侧的完整标签 *,例如 *.example.com,且只能出现一次",
"invalidFormat": "域名格式不合法", "invalidFormat": "域名格式不合法",
"invalidSubdomain": "子域名称格式不合法", "invalidSubdomain": "子域名称格式不合法",
"mustBelongToZone": "域名必须属于 Zone {root}", "mustBelongToZone": "域名必须属于 Zone {root}",
"domainAdded": "域名已添加", "domainAdded": "域名已添加",
"addFailed": "添加失败", "addFailed": "添加失败",
"quickCreateTitle": "快捷新增域名", "quickCreateTitle": "快捷新增域名",
"quickCreateDesc": "选择 Zone 后输入:子域名称(如 api)、@(根域)或完整 FQDN。", "quickCreateDesc": "输入子域名称(api)、@(根域)、*(根域下泛域名)或完整 FQDN。多级泛域名请填写完整名称,例如 *.s3.example.com,不支持 *.s3 简写。",
"selectRegisteredRoot": "选择注册根域", "selectRegisteredRoot": "选择注册根域",
"zoneLabel": "Zone:", "zoneLabel": "Zone:",
"domain": "域名", "domain": "域名",
"domainPlaceholderWithZone": "api 或 @ 或 api.{domain}", "domainPlaceholderWithZone": "api / @ / * / *.s3.{domain}",
"domainPlaceholder": "api / @ / 完整域名", "domainPlaceholder": "api / @ / * / 完整域名",
"willCreate": "将创建:", "willCreate": "将创建:",
"domainExample": "示例:输入 <api>api</api> → <fqdn>api.zone.com</fqdn>;<apex>@</apex> → 根域本身", "domainExample": "示例:<api>api</api> → <fqdn>api.zone.com</fqdn>;<apex>@</apex> → 根域;* → *.zone.com。泛域名不能直接打开,请使用匹配的具体主机名访问。",
"certOptional": "证书(可选)", "certOptional": "证书(可选)",
"noCert": "不绑定证书" "noCert": "不绑定证书"
}, },
@@ -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('*');
}
});
}); });
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package sync
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"math/big"
"path/filepath"
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
"github.com/Rain-kl/Wavelet/internal/apps/agent/state"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
)
func TestSyncWildcardConfig(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
cert := &x509.Certificate{SerialNumber: big.NewInt(1), DNSNames: []string{"*.s3.example.com"}}
der, err := x509.CreateCertificate(rand.Reader, cert, cert, publicKey, privateKey)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
t.Fatal(err)
}
files := []protocol.SupportFile{
{Path: "1.crt", Content: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))},
{Path: "1.key", Content: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}))},
}
for _, https := range []bool{false, true} {
t.Run(map[bool]string{false: "HTTP", true: "HTTPS"}[https], func(t *testing.T) {
doc := openrestyrender.Document{Routes: []openrestyrender.Route{{
ID: 1, SiteName: "wildcard", Domains: []string{"*.s3.example.com"},
OriginURL: "http://127.0.0.1:8080", EnableHTTPS: https, RedirectHTTP: https,
DomainCertIDs: []uint{1}, Enabled: true,
}}}
source, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
client := &fakeClient{config: protocol.ActiveConfigResponse{
Version: "wildcard-v1", Checksum: "wildcard-checksum", SourceConfigJSON: string(source), SupportFiles: files,
}}
dir := t.TempDir()
store := state.NewStore(filepath.Join(dir, "state.json"))
if _, err := store.EnsureNodeID(); err != nil {
t.Fatal(err)
}
manager := &fakeManager{}
service := New(client, manager, store)
if err := service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: client.config.Version, Checksum: client.config.Checksum}); err != nil {
t.Fatalf("SyncOnce(wildcard, HTTPS=%t) error = %v", https, err)
}
if len(manager.applyRouteContents) != 1 {
t.Fatalf("agent apply calls = %d, want 1", len(manager.applyRouteContents))
}
content := manager.applyRouteContents[0]
if !strings.Contains(content, "server_name *.s3.example.com;") {
t.Errorf("agent apply config missing wildcard server_name: %s", content)
}
if https && !strings.Contains(content, "listen 443 ssl;") {
t.Error("agent apply config missing wildcard HTTPS server")
}
if len(client.reports) != 1 || client.reports[0].Result != ApplyResultSuccess {
t.Errorf("SyncOnce(wildcard) apply reports = %+v, want success", client.reports)
}
snapshot, err := store.Load()
if err != nil || snapshot.CurrentVersion != client.config.Version {
t.Errorf("agent applied version = %+v, error=%v, want %s", snapshot, err, client.config.Version)
}
})
}
}
@@ -0,0 +1,135 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cloudflare
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
)
func TestWildcardMemberDNSRecords(t *testing.T) {
tests := []struct {
name, mode, recordType, content string
proxied bool
}{
{name: "custom_A", mode: model.CFPointingTargetModeCustom, recordType: "A", content: "203.0.113.20"},
{name: "custom_AAAA", mode: model.CFPointingTargetModeCustom, recordType: "AAAA", content: "2001:db8::20", proxied: true},
{name: "custom_CNAME", mode: model.CFPointingTargetModeCustom, recordType: "CNAME", content: "edge.example.net", proxied: true},
{name: "node_A", recordType: "A", content: "203.0.113.10", proxied: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, originalID := setupCloudflareLogicDB(t)
state, err := repository.GetCFPointingMemberContext(ctx, originalID)
if err != nil {
t.Fatal(err)
}
state.Group.TargetMode, state.Group.RecordType, state.Group.RecordContent = tt.mode, tt.recordType, tt.content
state.Group.DefaultProxied = tt.proxied
if err := repository.SaveCFPointingGroup(ctx, &state.Group); err != nil {
t.Fatal(err)
}
if tt.mode != model.CFPointingTargetModeCustom {
if err := repository.UpdateOpenFlareNodeColumns(ctx, &state.Node, map[string]any{"ip": tt.content}); err != nil {
t.Fatal(err)
}
}
domain := &model.ZoneDomain{ZoneID: state.Zone.ID, Domain: "*.s3.example.com"}
if err := repository.CreateZoneDomain(ctx, domain); err != nil {
t.Fatal(err)
}
available, err := ListAvailableDomains(ctx)
if err != nil || len(available) != 1 || available[0].Domain != domain.Domain {
t.Fatalf("ListAvailableDomains() = %+v, %v, want wildcard domain", available, err)
}
t.Cleanup(SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) { return "task-1", nil }))
member, err := CreateMember(ctx, state.Group.ID, MemberCreateInput{ZoneDomainID: domain.ID})
if err != nil {
t.Fatalf("CreateMember(wildcard) error = %v", err)
}
var creates, updates, deletes atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/zones":
if got := r.URL.Query().Get("name"); got != "example.com" {
t.Errorf("FindZone name = %q, want example.com", got)
}
writeCFTestResponse(t, w, []Zone{{ID: "zone-1", Name: "example.com"}})
case "/zones/zone-1/dns_records", "/zones/zone-1/dns_records/wildcard-record":
if (r.Method == http.MethodPut || r.Method == http.MethodDelete) && r.URL.Path != "/zones/zone-1/dns_records/wildcard-record" {
t.Errorf("DNS %s path = %q, want record ID path", r.Method, r.URL.Path)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
switch r.Method {
case http.MethodGet:
if r.URL.Path == "/zones/zone-1/dns_records/wildcard-record" {
writeCFTestResponse(t, w, DNSRecord{ID: "wildcard-record", Name: domain.Domain, Type: tt.recordType})
return
}
if r.URL.Query().Get("name") != domain.Domain || r.URL.Query().Get("type") != tt.recordType {
t.Errorf("ListRecords query = %v, want wildcard name and %s", r.URL.Query(), tt.recordType)
}
writeCFTestResponse(t, w, []DNSRecord{})
case http.MethodPost, http.MethodPut:
var input RecordInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Errorf("Decode(record) error = %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
wantTTL := 300
if tt.proxied {
wantTTL = 1
}
if input.Name != domain.Domain || input.Type != tt.recordType || input.Content != tt.content || input.Proxied != tt.proxied || input.TTL != wantTTL {
t.Errorf("DNS record input = %+v, want name=%s type=%s content=%s proxied=%t TTL=%d", input, domain.Domain, tt.recordType, tt.content, tt.proxied, wantTTL)
}
if r.Method == http.MethodPost {
creates.Add(1)
} else {
updates.Add(1)
}
writeCFTestResponse(t, w, DNSRecord{ID: "wildcard-record", Name: input.Name, Type: input.Type, Content: input.Content})
case http.MethodDelete:
deletes.Add(1)
writeCFTestResponse(t, w, map[string]string{"id": "wildcard-record"})
default:
t.Errorf("unexpected method %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
}
default:
t.Errorf("unexpected Cloudflare path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
t.Cleanup(SetClientFactoryForTest(func(token string) Client {
return NewHTTPClient(token, WithBaseURL(server.URL), WithHTTPClient(server.Client()))
}))
for range 2 {
if err := ReconcileMember(ctx, member.ID); err != nil {
t.Fatalf("ReconcileMember(wildcard %s) error = %v", tt.recordType, err)
}
}
stored, err := repository.GetCFPointingMemberByID(ctx, member.ID)
if err != nil || stored.SyncStatus != model.CFMemberSyncOK || stored.CFRecordID != "wildcard-record" {
t.Fatalf("synced wildcard member = %+v, error=%v", stored, err)
}
if err := DeleteManagedRecord(ctx, member.ID); err != nil {
t.Fatalf("DeleteManagedRecord(wildcard) error = %v", err)
}
if creates.Load() != 1 || updates.Load() != 1 || deletes.Load() != 1 {
t.Errorf("DNS create/update/delete counts = %d/%d/%d, want 1/1/1", creates.Load(), updates.Load(), deletes.Load())
}
})
}
}
@@ -0,0 +1,65 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
oftls "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/zone"
"github.com/Rain-kl/Wavelet/internal/infra/config"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
)
func TestWildcardDomainConfigBundle(t *testing.T) {
t.Cleanup(setupConfigVersionTestDB(t))
ctx := context.Background()
if err := db.DB(ctx).AutoMigrate(&model.TLSCertificate{}); err != nil {
t.Fatal(err)
}
oldSecret := config.Config.App.SessionSecret
config.Config.App.SessionSecret = "wildcard-snapshot-test-secret"
t.Cleanup(func() { config.Config.App.SessionSecret = oldSecret })
certPEM, keyPEM := generateTestCertKeyPairForSnapshotForDomain(t, "*.s3.example.com")
cert, err := oftls.CreateCertificate(ctx, oftls.CertificateInput{Name: "wildcard", CertPEM: certPEM, KeyPEM: keyPEM})
if err != nil {
t.Fatal(err)
}
root, err := zone.Create(ctx, zone.Input{Domain: "example.com"})
if err != nil {
t.Fatal(err)
}
domain, err := zone.CreateDomain(ctx, root.ID, zone.DomainInput{Domain: "*.s3.example.com", CertID: &cert.ID})
if err != nil {
t.Fatalf("CreateDomain(wildcard) error = %v", err)
}
route := &model.ProxyRoute{SiteName: "wildcard-site", OriginURL: "http://origin:8080", Upstreams: `["http://origin:8080"]`, Enabled: true, EnableHTTPS: true, RedirectHTTP: true}
if err := repository.CreateProxyRouteRecord(ctx, route); err != nil {
t.Fatal(err)
}
if err := repository.ReplaceZoneDomainRouteBindings(ctx, route.ID, []uint{domain.ID}); err != nil {
t.Fatal(err)
}
bundle, err := buildCurrentConfigBundle(ctx, true)
if err != nil {
t.Fatalf("buildCurrentConfigBundle(wildcard) error = %v", err)
}
var doc openrestyrender.Document
if err := json.Unmarshal([]byte(bundle.SnapshotJSON), &doc); err != nil {
t.Fatal(err)
}
if len(doc.Routes) != 1 || !slices.Equal(doc.Routes[0].Domains, []string{domain.Domain}) || !slices.Equal(doc.Routes[0].DomainCertIDs, []uint{cert.ID}) {
t.Fatalf("snapshot routes = %+v, want wildcard domain and its certificate", doc.Routes)
}
if !strings.Contains(bundle.RouteConfig, "server_name *.s3.example.com;") || !strings.Contains(bundle.RouteConfig, "listen 443 ssl;") {
t.Errorf("wildcard bundle missing HTTPS server: %s", bundle.RouteConfig)
}
}
@@ -305,9 +305,21 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{ rec := performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": "*.example.com", "domain": "*.example.com",
}, adminAuthHeaders(seed.Token)) }, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusBadRequest, rec.Code) if rec.Code != http.StatusOK {
errResp := decodeAPIResponse(t, rec) t.Fatalf("create wildcard domain status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
assert.NotEmpty(t, errResp.ErrorMsg) }
wildcardData := unmarshalAPIMap(t, requireAPIOK(t, rec).Data)
if wildcardData["domain"] != "*.example.com" {
t.Errorf("created domain = %v, want *.example.com", wildcardData["domain"])
}
for _, invalid := range []string{"a.*.example.com", "*.other.com", "*.example.com;"} {
rejected := performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": invalid,
}, adminAuthHeaders(seed.Token))
if rejected.Code != http.StatusBadRequest || decodeAPIResponse(t, rejected).ErrorMsg == "" {
t.Errorf("create domain %q status = %d, body = %s, want 400 with error message", invalid, rejected.Code, rejected.Body.String())
}
}
rec = performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{ rec = performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": "security.example.com", "cert_id": certID, "domain": "security.example.com", "cert_id": certID,
+10 -4
View File
@@ -229,11 +229,17 @@ func routeMonitorURL(ctx context.Context, route *model.ProxyRoute) (string, erro
if len(domains) == 0 { if len(domains) == 0 {
return "", fmt.Errorf("route %s has no zone domains", route.SiteName) return "", fmt.Errorf("route %s has no zone domains", route.SiteName)
} }
domain := domains[0].Domain // 通配符是匹配规则而非可探测地址;混合绑定时选择具体域名。
if route.EnableHTTPS { for _, domain := range domains {
return "https://" + domain, nil if strings.Contains(domain.Domain, "*") {
continue
} }
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")
}
}
-1
View File
@@ -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"
+27 -5
View File
@@ -13,6 +13,7 @@ import (
"strings" "strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity" "github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity"
"golang.org/x/net/idna"
) )
// ImportReport describes the idempotent legacy migration result. // ImportReport describes the idempotent legacy migration result.
@@ -82,7 +83,7 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo
} }
for _, item := range items { for _, item := range items {
domain, normErr := normalizeDomain(item.Domain) domain, normErr := normalizeLegacyDomain(item.Domain)
if normErr != nil { if normErr != nil {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", item.Domain, normErr)) report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", item.Domain, normErr))
continue continue
@@ -95,22 +96,28 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo
var existingID uint var existingID uint
var existingZoneDomain string var existingZoneDomain string
var existingRouteID, existingCertID sql.Null[uint]
scanErr := tx.QueryRowContext(ctx, q(` scanErr := tx.QueryRowContext(ctx, q(`
SELECT zd.id, z.domain SELECT zd.id, z.domain, zd.proxy_route_id, zd.cert_id
FROM of_zone_domains zd FROM of_zone_domains zd
JOIN of_zones z ON z.id = zd.zone_id JOIN of_zones z ON z.id = zd.zone_id
WHERE zd.domain = ? WHERE zd.domain = ?
`), domain).Scan(&existingID, &existingZoneDomain) `), domain).Scan(&existingID, &existingZoneDomain, &existingRouteID, &existingCertID)
if scanErr == nil { if scanErr == nil {
if (item.ProxyRouteID != nil && existingRouteID.Valid && existingRouteID.V != *item.ProxyRouteID) ||
(item.CertID != nil && existingCertID.Valid && existingCertID.V != *item.CertID) {
report.Conflicts = append(report.Conflicts, domain+": canonical domain binding conflict")
continue
}
if existingZoneDomain != root { if existingZoneDomain != root {
report.Conflicts = append(report.Conflicts, domain+": global domain conflict") report.Conflicts = append(report.Conflicts, domain+": global domain conflict")
} else if item.ProxyRouteID != nil { } else if item.ProxyRouteID != nil || item.CertID != nil {
if _, bindErr := tx.ExecContext(ctx, q(` if _, bindErr := tx.ExecContext(ctx, q(`
UPDATE of_zone_domains UPDATE of_zone_domains
SET proxy_route_id = COALESCE(proxy_route_id, ?), SET proxy_route_id = COALESCE(proxy_route_id, ?),
cert_id = COALESCE(cert_id, ?) cert_id = COALESCE(cert_id, ?)
WHERE id = ? WHERE id = ?
`), *item.ProxyRouteID, nullableUint(item.CertID), existingID); bindErr != nil { `), nullableUint(item.ProxyRouteID), nullableUint(item.CertID), existingID); bindErr != nil {
return report, bindErr return report, bindErr
} }
} }
@@ -152,6 +159,21 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo
return report, nil return report, nil
} }
// normalizeLegacyDomain canonicalizes historical IDNs for DNS identity and rendering.
// Certificate associations and route bindings remain attached to the imported row.
func normalizeLegacyDomain(raw string) (string, error) {
domain := strings.ToLower(strings.TrimSpace(raw))
host := strings.TrimPrefix(domain, "*.")
ascii, err := idna.Lookup.ToASCII(host)
if err != nil {
return "", errors.New(errDomainInvalid)
}
if host != domain {
ascii = "*." + ascii
}
return normalizeDomain(ascii)
}
func ensureZone( func ensureZone(
ctx context.Context, ctx context.Context,
tx *sql.Tx, tx *sql.Tx,
@@ -148,3 +148,117 @@ func TestImportLegacyTxNoOpWithoutLegacyColumns(t *testing.T) {
assert.Equal(t, 0, report.Zones) assert.Equal(t, 0, report.Zones)
assert.Equal(t, 0, report.Domains) assert.Equal(t, 0, report.Domains)
} }
// TestImportLegacyTxUnicodeDomains canonicalizes historical names and preserves bindings on upgrade.
func TestImportLegacyTxUnicodeDomains(t *testing.T) {
for _, source := range []string{"route", "managed"} {
t.Run(source, func(t *testing.T) {
sqlDB, cleanup := setupLegacyImportDB(t)
t.Cleanup(cleanup)
ctx := context.Background()
if _, err := sqlDB.Exec(`INSERT INTO of_tls_certificates (id, name) VALUES (7, 'cert')`); err != nil {
t.Fatal(err)
}
var err error
if source == "route" {
_, err = sqlDB.Exec(`INSERT INTO of_proxy_routes (id, domain, domains, domain_cert_ids) VALUES (3, 'api.bücher.de', '["api.bücher.de"]', '[7]')`)
} else {
_, err = sqlDB.Exec(`INSERT INTO of_managed_domains (domain, cert_id) VALUES ('api.bücher.de', 7)`)
}
if err != nil {
t.Fatal(err)
}
for attempt := range 2 {
tx, err := sqlDB.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = tx.Rollback() })
report, err := ImportLegacyTx(ctx, tx, false)
if err != nil {
t.Fatalf("ImportLegacyTx(%s, attempt=%d) = %+v, %v, want no conflicts", source, attempt, report, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
want := 1 - attempt
if report.Zones != want || report.Domains != want {
t.Errorf("ImportLegacyTx(%s, attempt=%d) = %+v, want %d zones/domains", source, attempt, report, want)
}
}
var root, domain string
var certID uint
var routeID sql.NullInt64
if err := sqlDB.QueryRow(`SELECT z.domain, zd.domain, zd.cert_id, zd.proxy_route_id FROM of_zone_domains zd JOIN of_zones z ON z.id = zd.zone_id`).Scan(&root, &domain, &certID, &routeID); err != nil {
t.Fatal(err)
}
if root != "xn--bcher-kva.de" || domain != "api.xn--bcher-kva.de" || certID != 7 {
t.Errorf("imported %s = (%q, %q, cert=%d), want (xn--bcher-kva.de, api.xn--bcher-kva.de, cert=7)", source, root, domain, certID)
}
if (source == "route" && (!routeID.Valid || routeID.Int64 != 3)) || (source == "managed" && routeID.Valid) {
t.Errorf("imported %s route binding = %v", source, routeID)
}
var zoneID uint
if err := sqlDB.QueryRow(`SELECT id FROM of_zones`).Scan(&zoneID); err != nil {
t.Fatal(err)
}
if _, err := Create(ctx, Input{Domain: "xn--bcher-kva.de"}); err == nil {
t.Error("Create accepted an equivalent imported IDN zone")
}
if _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "api.xn--bcher-kva.de"}); err == nil {
t.Error("CreateDomain accepted an equivalent imported IDN host")
}
if _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "www.xn--bcher-kva.de"}); err != nil {
t.Errorf("CreateDomain in imported IDN zone: %v", err)
}
if _, err := normalizeDomain("api.bücher.de"); err == nil {
t.Error("normalizeDomain(api.bücher.de) accepted a new Unicode domain")
}
})
}
}
// TestImportLegacyTxIDNBindingConflict rejects equivalent names bound to different routes.
func TestImportLegacyTxIDNBindingConflict(t *testing.T) {
sqlDB, cleanup := setupLegacyImportDB(t)
t.Cleanup(cleanup)
if _, err := sqlDB.Exec(`INSERT INTO of_proxy_routes (id, domain, domains) VALUES (3, 'api.bücher.de', '["api.bücher.de"]'), (4, 'api.xn--bcher-kva.de', '["api.xn--bcher-kva.de"]')`); err != nil {
t.Fatal(err)
}
tx, err := sqlDB.Begin()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = tx.Rollback() })
report, err := ImportLegacyTx(context.Background(), tx, false)
if err == nil || len(report.Conflicts) != 1 {
t.Errorf("ImportLegacyTx(equivalent IDN with different bindings) = %+v, %v; want one conflict", report, err)
}
}
// TestNormalizeLegacyIDNRoot resolves Unicode public suffixes through their ASCII identity.
func TestNormalizeLegacyIDNRoot(t *testing.T) {
domain, err := normalizeLegacyDomain("api.example.公司.cn")
if err != nil {
t.Fatal(err)
}
root, err := zoneRoot(domain)
if err != nil || root != "example.xn--55qx5d.cn" {
t.Errorf("zoneRoot(%q) = %q, %v; want example.xn--55qx5d.cn", domain, root, err)
}
}
// TestNormalizeLegacyIDNCanonicalForms keeps Unicode normalization and wildcard identity consistent.
func TestNormalizeLegacyIDNCanonicalForms(t *testing.T) {
for _, raw := range []string{"api.bücher.de", "api.bu\u0308cher.de", "API.XN--BCHER-KVA.DE"} {
domain, err := normalizeLegacyDomain(raw)
if err != nil || domain != "api.xn--bcher-kva.de" {
t.Errorf("normalizeLegacyDomain(%q) = %q, %v; want api.xn--bcher-kva.de", raw, domain, err)
}
}
domain, err := normalizeLegacyDomain("*.bücher.de")
if err != nil || domain != "*.xn--bcher-kva.de" {
t.Errorf("normalizeLegacyDomain(*.bücher.de) = %q, %v; want *.xn--bcher-kva.de", domain, err)
}
}
+19 -7
View File
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package zone manages registered roots and their explicit hostnames. // Package zone manages registered roots and their exact or wildcard hostnames.
package zone package zone
import ( import (
@@ -18,6 +18,8 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const maxDomainLength = 253
// Input is the mutable Zone payload. // Input is the mutable Zone payload.
type Input struct { type Input struct {
Domain string `json:"domain"` Domain string `json:"domain"`
@@ -45,7 +47,8 @@ type ListItem struct {
} }
func zoneRoot(domain string) (string, error) { func zoneRoot(domain string) (string, error) {
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) domain = strings.ToLower(strings.TrimSpace(domain))
return publicsuffix.EffectiveTLDPlusOne(strings.TrimPrefix(domain, "*."))
} }
func normalizeDomain(raw string) (string, error) { func normalizeDomain(raw string) (string, error) {
@@ -53,12 +56,21 @@ func normalizeDomain(raw string) (string, error) {
if domain == "" { if domain == "" {
return "", errors.New(errZoneDomainRequired) return "", errors.New(errZoneDomainRequired)
} }
if strings.Contains(domain, "*") { // 只允许最左侧完整标签为通配符;其余字符必须是 DNS 标签,不能进入配置语法。
return "", errors.New(errDomainWildcardUnsupported) host := strings.TrimPrefix(domain, "*.")
} if len(domain) > maxDomainLength {
if strings.Contains(domain, "://") || strings.Contains(domain, "/") || strings.Contains(domain, "?") || strings.Contains(domain, "#") || strings.Contains(domain, "@") {
return "", errors.New(errDomainInvalid) return "", errors.New(errDomainInvalid)
} }
for _, label := range strings.Split(host, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return "", errors.New(errDomainInvalid)
}
for _, char := range label {
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
return "", errors.New(errDomainInvalid)
}
}
}
if _, err := zoneRoot(domain); err != nil { if _, err := zoneRoot(domain); err != nil {
return "", errors.New(errDomainInvalid) return "", errors.New(errDomainInvalid)
} }
@@ -151,7 +163,7 @@ func GetOverview(ctx context.Context, id uint) (*Overview, error) {
return &Overview{Zone: *zone, Domains: domains}, nil return &Overview{Zone: *zone, Domains: domains}, nil
} }
// CreateDomain adds a validated exact hostname to a Zone. // CreateDomain adds a validated exact or wildcard hostname to a Zone.
func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error) { func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error) {
zone, err := repository.GetZoneByID(ctx, zoneID) zone, err := repository.GetZoneByID(ctx, zoneID)
if err != nil { if err != nil {
+90 -4
View File
@@ -5,6 +5,7 @@ package zone
import ( import (
"context" "context"
"strings"
"testing" "testing"
"time" "time"
@@ -28,12 +29,97 @@ func setupZoneDB(t *testing.T) context.Context {
return context.Background() return context.Background()
} }
func TestCreateZoneDomainRejectsWildcard(t *testing.T) { func TestNormalizeDomainWildcard(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: " *.Example.COM ", want: "*.example.com"},
{input: "*.s3.example.com", want: "*.s3.example.com"},
{input: "*.example.co.uk", want: "*.example.co.uk"},
{input: "API.Example.com", want: "api.example.com"},
{input: "example.com", want: "example.com"},
{input: "*"}, {input: "*.com"}, {input: "*.co.uk"},
{input: "foo*.example.com"}, {input: "a.*.example.com"},
{input: "*.*.example.com"}, {input: "**.example.com"},
{input: "*.example.com."}, {input: "*..example.com"},
{input: "*.-s3.example.com"}, {input: "*.s3-.example.com"},
{input: "*.s_3.example.com"}, {input: "*.example.com:443"},
{input: "*.example.com;"}, {input: "*.a b.example.com"},
{input: "*.a\nb.example.com"}, {input: "https://*.example.com"},
{input: "*.example.com/path"}, {input: "*.example.com?query"},
{input: "*.example.com#fragment"}, {input: "*@example.com"},
{input: "*." + strings.Repeat("a", 64) + ".example.com"},
{input: "*." + strings.Repeat(strings.Repeat("a", 63)+".", 4) + "com"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := normalizeDomain(tt.input)
if got != tt.want || (err != nil) != (tt.want == "") {
t.Errorf("normalizeDomain(%q) = (%q, %v), want %q with error=%t", tt.input, got, err, tt.want, tt.want == "")
}
})
}
}
func TestCreateAndUpdateWildcardDomain(t *testing.T) {
ctx := setupZoneDB(t) ctx := setupZoneDB(t)
zone, err := Create(ctx, Input{Domain: "example.com"}) zone, err := Create(ctx, Input{Domain: "example.com"})
require.NoError(t, err) if err != nil {
_, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.example.com"}) t.Fatal(err)
require.EqualError(t, err, errDomainWildcardUnsupported) }
item, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: " *.Example.COM "})
if err != nil {
t.Fatalf("CreateDomain(wildcard) error = %v", err)
}
if item.Domain != "*.example.com" {
t.Errorf("CreateDomain(wildcard).Domain = %q, want *.example.com", item.Domain)
}
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.example.com"}); err == nil {
t.Error("CreateDomain(duplicate wildcard) error = nil, want duplicate error")
}
// 精确域名可以和通配符共存,路由匹配优先级交给 OpenResty。
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "api.example.com"}); err != nil {
t.Fatalf("CreateDomain(exact alongside wildcard) error = %v", err)
}
for _, domain := range []string{"*.s3.example.com", "www.example.com", "*.example.com"} {
updated, err := UpdateDomain(ctx, zone.ID, item.ID, DomainInput{Domain: domain})
if err != nil {
t.Fatalf("UpdateDomain(%q) error = %v", domain, err)
}
if updated.Domain != domain {
t.Errorf("UpdateDomain(%q).Domain = %q", domain, updated.Domain)
}
}
for _, domain := range []string{"*.other.com", "*.example.com.evil.net", "a.*.example.com"} {
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: domain}); err == nil {
t.Errorf("CreateDomain(%q) error = nil, want invalid/outside-zone error", domain)
}
if _, err := UpdateDomain(ctx, zone.ID, item.ID, DomainInput{Domain: domain}); err == nil {
t.Errorf("UpdateDomain(%q) error = nil, want invalid/outside-zone error", domain)
}
}
stored, err := repository.GetZoneDomainByZoneAndID(ctx, zone.ID, item.ID)
if err != nil || stored.Domain != "*.example.com" {
t.Fatalf("stored domain after rejected updates = %+v, error=%v, want *.example.com", stored, err)
}
}
func TestZoneRootRejectsWildcard(t *testing.T) {
ctx := setupZoneDB(t)
if _, err := Create(ctx, Input{Domain: "*.example.com"}); err == nil {
t.Error("Create(wildcard zone) error = nil, want invalid root error")
}
zone, err := Create(ctx, Input{Domain: "example.co.uk"})
if err != nil {
t.Fatal(err)
}
if _, err := Update(ctx, zone.ID, Input{Domain: "*.example.co.uk"}); err == nil {
t.Error("Update(wildcard zone) error = nil, want invalid root error")
}
if _, err := CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.s3.example.co.uk"}); err != nil {
t.Errorf("CreateDomain(wildcard under multi-label public suffix) error = %v", err)
}
} }
func TestDeleteDomainRejectsBoundRoute(t *testing.T) { func TestDeleteDomainRejectsBoundRoute(t *testing.T) {
+3 -1
View File
@@ -161,8 +161,9 @@ func DeleteHandler(c *gin.Context) {
c.JSON(http.StatusOK, response.OKNil()) c.JSON(http.StatusOK, response.OKNil())
} }
// CreateDomainHandler creates an explicit FQDN under a Zone. // CreateDomainHandler creates an exact or wildcard FQDN under a Zone.
// @Summary 创建 Zone 域名 // @Summary 创建 Zone 域名
// @Description 支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。
// @Tags openflare-zone // @Tags openflare-zone
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -192,6 +193,7 @@ func CreateDomainHandler(c *gin.Context) {
// UpdateDomainHandler updates a Zone domain. // UpdateDomainHandler updates a Zone domain.
// @Summary 更新 Zone 域名 // @Summary 更新 Zone 域名
// @Description 支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。
// @Tags openflare-zone // @Tags openflare-zone
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -0,0 +1,105 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/google/go-cmp/cmp"
)
func TestGetStatsWildcardHosts(t *testing.T) {
type totals struct {
Requests int64
Visitors int64
Bytes int64
}
tests := []struct {
name string
domains []string
want totals
buckets map[int]totals
}{
{
name: "wildcard_matches_multiple_levels_without_root",
domains: []string{"*.example.com"}, want: totals{Requests: 5, Visitors: 3, Bytes: 150},
buckets: map[int]totals{0: {Requests: 2, Visitors: 1, Bytes: 30}, 1: {Requests: 3, Visitors: 2, Bytes: 120}},
},
{
name: "nested_wildcard_excludes_its_suffix_root",
domains: []string{"*.s3.example.com"}, want: totals{Requests: 2, Visitors: 1, Bytes: 70},
buckets: map[int]totals{1: {Requests: 2, Visitors: 1, Bytes: 70}},
},
{
name: "overlapping_exact_and_wildcards_are_not_summed_twice",
domains: []string{"api.example.com", "*.example.com", "*.s3.example.com"}, want: totals{Requests: 5, Visitors: 3, Bytes: 150},
buckets: map[int]totals{0: {Requests: 2, Visitors: 1, Bytes: 30}, 1: {Requests: 3, Visitors: 2, Bytes: 120}},
},
{
name: "exact_root_can_be_included_explicitly",
domains: []string{"example.com", "*.s3.example.com"}, want: totals{Requests: 3, Visitors: 2, Bytes: 130},
buckets: map[int]totals{0: {Requests: 1, Visitors: 1, Bytes: 60}, 1: {Requests: 2, Visitors: 1, Bytes: 70}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := setupZoneDB(t)
testhelper.SetupLogStoresForTest(t)
site, err := Create(ctx, Input{Domain: "example.com"})
if err != nil {
t.Fatalf("Create(example.com) error = %v, want nil", err)
}
// 直接登记域名,隔离流量统计与业务层域名校验的回归范围。
for _, domain := range tt.domains {
if err := repository.CreateZoneDomain(ctx, &model.ZoneDomain{ZoneID: site.ID, Domain: domain}); err != nil {
t.Fatalf("CreateZoneDomain(%q) error = %v, want nil", domain, err)
}
}
base := time.Now().UTC().Truncate(time.Hour).Add(-3 * time.Hour)
rows := []*model.OpenFlareAccessLog{
{Host: "a..example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "5.5.5.5", BytesSent: 1000},
{Host: ".a.example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "5.5.5.5", BytesSent: 1000},
{Host: "api.example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "1.1.1.1", BytesSent: 10},
{Host: " Deep.API.Example.COM ", LoggedAt: base.Add(2 * time.Minute), RemoteAddr: "1.1.1.1", BytesSent: 20},
{Host: "bucket.s3.example.com", LoggedAt: base.Add(time.Hour + time.Minute), RemoteAddr: "2.2.2.2", BytesSent: 30},
{Host: "deep.bucket.s3.example.com", LoggedAt: base.Add(time.Hour + 2*time.Minute), BytesSent: 40},
{Host: "s3.example.com", LoggedAt: base.Add(time.Hour + 3*time.Minute), RemoteAddr: "3.3.3.3", BytesSent: 50},
{Host: "example.com", LoggedAt: base.Add(3 * time.Minute), RemoteAddr: "1.1.1.1", BytesSent: 60},
{Host: "evil-example.com", LoggedAt: base.Add(time.Minute), RemoteAddr: "9.9.9.9", BytesSent: 70},
{Host: "bucket.s3.example.com.evil", LoggedAt: base.Add(time.Minute), RemoteAddr: "9.9.9.9", BytesSent: 80},
{Host: "bucket.s3.example.com", LoggedAt: base.Add(-48 * time.Hour), RemoteAddr: "9.9.9.9", BytesSent: 90},
}
if err := repository.InsertOpenFlareAccessLogsBatch(ctx, rows); err != nil {
t.Fatalf("InsertOpenFlareAccessLogsBatch(wildcard fixtures) error = %v, want nil", err)
}
stats, err := GetStats(ctx, site.ID, "24h")
if err != nil {
t.Fatalf("GetStats(%d, 24h) error = %v, want nil", site.ID, err)
}
if got := (totals{Requests: stats.RequestCount, Visitors: stats.UniqueVisitors, Bytes: stats.BytesSent}); got != tt.want {
t.Errorf("GetStats(domains=%v) totals = %+v, want %+v", tt.domains, got, tt.want)
}
if !stats.Available || stats.DomainCount != len(tt.domains) {
t.Errorf("GetStats(domains=%v) available/domain_count = %t/%d, want true/%d", tt.domains, stats.Available, stats.DomainCount, len(tt.domains))
}
wantBuckets := make(map[int64]totals)
for offset, total := range tt.buckets {
wantBuckets[base.Add(time.Duration(offset)*time.Hour).Unix()] = total
}
gotBuckets := make(map[int64]totals)
for _, point := range stats.Series {
if point.RequestCount != 0 || point.UniqueVisitors != 0 || point.BytesSent != 0 {
gotBuckets[point.BucketStartedAt.Unix()] = totals{Requests: point.RequestCount, Visitors: point.UniqueVisitors, Bytes: point.BytesSent}
}
}
if diff := cmp.Diff(wantBuckets, gotBuckets); diff != "" {
t.Errorf("GetStats(domains=%v) nonempty series mismatch (-want +got):\n%s", tt.domains, diff)
}
})
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ type NodeAccessLogFilter struct {
NodeID string NodeID string
RemoteAddr string RemoteAddr string
Host string Host string
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes. // Hosts 优先于 Host:具体域名精确匹配(忽略大小写),*.suffix 匹配非空、多层子域名但不含 suffix 本身。
Hosts []string Hosts []string
Path string Path string
// StatusCode filters by exact HTTP status code when > 0. // StatusCode filters by exact HTTP status code when > 0.
+4 -1
View File
@@ -160,7 +160,9 @@ type OpenFlareAccessLogQuery struct {
NodeID string NodeID string
RemoteAddr string RemoteAddr string
Host string Host string
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes. // Hosts matches case-insensitively and takes precedence over Host.
// Exact names match only that host; *.example.com matches one or more
// nonempty subdomain levels, excluding example.com itself.
Hosts []string Hosts []string
Path string Path string
// StatusCode filters by exact HTTP status code when > 0. // StatusCode filters by exact HTTP status code when > 0.
@@ -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
+2 -2
View File
@@ -25,7 +25,7 @@ func (Zone) TableName() string {
return tableOfZones return tableOfZones
} }
// ZoneDomain OpenFlare Zone 下的明确域名实体。 // ZoneDomain OpenFlare Zone 下的具体或通配符域名实体。
type ZoneDomain struct { type ZoneDomain struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"` ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
ZoneID uint `json:"zone_id" gorm:"not null;index:idx_of_zone_domains_zone_id"` ZoneID uint `json:"zone_id" gorm:"not null;index:idx_of_zone_domains_zone_id"`
@@ -41,7 +41,7 @@ func (ZoneDomain) TableName() string {
return tableOfZoneDomains return tableOfZoneDomains
} }
// ZoneDomainCount is the per-zone explicit domain count for list queries. // ZoneDomainCount 是每个 Zone 的登记域名记录数,通配符按一条记录计数。
type ZoneDomainCount struct { type ZoneDomainCount struct {
ZoneID uint `json:"zone_id" gorm:"column:zone_id"` ZoneID uint `json:"zone_id" gorm:"column:zone_id"`
Count int64 `json:"count" gorm:"column:count"` Count int64 `json:"count" gorm:"column:count"`
@@ -42,12 +42,28 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any)
} }
hosts := normalizeNodeAccessLogHosts(filter.Hosts) hosts := normalizeNodeAccessLogHosts(filter.Hosts)
if len(hosts) > 0 { if len(hosts) > 0 {
hostParts := make([]string, 0, len(hosts))
placeholders := make([]string, 0, len(hosts)) placeholders := make([]string, 0, len(hosts))
var exactHosts []any
for _, host := range hosts { for _, host := range hosts {
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))
}
})
}
}
+22 -4
View File
@@ -814,7 +814,7 @@ func toNodeAccessLogFilter(query model.OpenFlareAccessLogQuery) analyticsmodel.N
} }
// buildNodeAccessLogFilterParts 拼装节点访问日志过滤条件(node_id 等值、remote_addr/host/path // buildNodeAccessLogFilterParts 拼装节点访问日志过滤条件(node_id 等值、remote_addr/host/path
// 前缀 LIKE、hosts 走 lower(trim(host)) IN、since/until 时间窗,顺序与 applyNodeAccessLogFilter // 前缀 LIKE、hosts 精确域名或通配符后缀、since/until 时间窗,顺序与 applyNodeAccessLogFilter
// 完全一致),返回 WHERE 片段与参数;无任何条件时返回空串与 nil。 // 完全一致),返回 WHERE 片段与参数;无任何条件时返回空串与 nil。
func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string, []any) { func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string, []any) {
var parts []string var parts []string
@@ -829,8 +829,26 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string
} }
hosts := normalizeNodeAccessLogHosts(f.Hosts) hosts := normalizeNodeAccessLogHosts(f.Hosts)
if len(hosts) > 0 { if len(hosts) > 0 {
parts = append(parts, "lower(trim(host)) IN ?") var hostParts []string
args = append(args, hosts) var exactHosts []string
for _, host := range hosts {
if suffix, wildcard := strings.CutPrefix(host, "*."); wildcard && suffix != "" {
// 与 CH 一致:非空子域名,可跨多层,但不匹配 suffix 根自身。
hostParts = append(hostParts, `(lower(trim(host)) LIKE ? ESCAPE '\' AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')`)
args = append(args, "_%."+util.EscapeLike(suffix))
continue
}
exactHosts = append(exactHosts, host)
}
if len(exactHosts) > 0 {
hostParts = append(hostParts, "lower(trim(host)) IN ?")
args = append(args, exactHosts)
}
hostClause := strings.Join(hostParts, " OR ")
if len(hostParts) > 1 {
hostClause = "(" + hostClause + ")"
}
parts = append(parts, hostClause)
} else if host := strings.TrimSpace(f.Host); host != "" { } else if host := strings.TrimSpace(f.Host); host != "" {
parts = append(parts, `host LIKE ? ESCAPE '\'`) parts = append(parts, `host LIKE ? ESCAPE '\'`)
args = append(args, util.EscapeLike(host)+"%") args = append(args, util.EscapeLike(host)+"%")
@@ -858,7 +876,7 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string
} }
// applyNodeAccessLogFilter 对齐 CH 过滤语义(node_access_log_filter.go): // applyNodeAccessLogFilter 对齐 CH 过滤语义(node_access_log_filter.go):
// node_id trim 后等值;remote_addr/host/path 前缀 LIKE;hosts 走 lower(trim(host)) IN(参数已归一化); // node_id trim 后等值;remote_addr/host/path 前缀 LIKE;hosts 精确域名或通配符后缀(参数已归一化);
// since 闭区间 >=;until 开区间 <。 // since 闭区间 >=;until 开区间 <。
func applyNodeAccessLogFilter(q *gorm.DB, f analyticsmodel.NodeAccessLogFilter) *gorm.DB { func applyNodeAccessLogFilter(q *gorm.DB, f analyticsmodel.NodeAccessLogFilter) *gorm.DB {
cond, args := buildNodeAccessLogFilterParts(f) cond, args := buildNodeAccessLogFilterParts(f)
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package openresty
import (
"regexp"
"strings"
"testing"
)
func TestWildcardCertificateCoverage(t *testing.T) {
tests := []struct {
certDomain string
domain string
wantErr bool
}{
{certDomain: "*.s3.example.com", domain: "*.s3.example.com"},
{certDomain: "*.s3.example.com", domain: "bucket.s3.example.com"},
{certDomain: "*.example.com", domain: "*.s3.example.com", wantErr: true},
{certDomain: "bucket.s3.example.com", domain: "*.s3.example.com", wantErr: true},
{certDomain: "s3.example.com", domain: "*.s3.example.com", wantErr: true},
{certDomain: "*.s3.example.com", domain: "s3.example.com", wantErr: true},
{certDomain: "*.s3.example.com", domain: "nested.bucket.s3.example.com", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.certDomain+"_for_"+tt.domain, func(t *testing.T) {
err := validateCertificateCoverage(testCertificatePEMForDomain(t, tt.certDomain), []string{tt.domain})
if (err != nil) != tt.wantErr {
t.Errorf("validateCertificateCoverage(cert=%q, domain=%q) error = %v, want error=%t", tt.certDomain, tt.domain, err, tt.wantErr)
}
})
}
}
func TestRenderWildcardAndExactRoutes(t *testing.T) {
for _, https := range []bool{false, true} {
t.Run(map[bool]string{false: "HTTP", true: "HTTPS"}[https], func(t *testing.T) {
doc := Document{Routes: []Route{
{ID: 1, SiteName: "wildcard", Domains: []string{"*.s3.example.com"}, OriginURL: "http://wildcard-origin:8080", EnableHTTPS: https, RedirectHTTP: https, DomainCertIDs: []uint{1}},
{ID: 2, SiteName: "exact", Domains: []string{"special.s3.example.com"}, OriginURL: "http://exact-origin:8080", EnableHTTPS: https, RedirectHTTP: https, DomainCertIDs: []uint{1}},
}}
files := []SupportFile{{Path: "1.crt", Content: testCertificatePEMForDomain(t, "*.s3.example.com")}}
got, err := RenderRouteConfig(doc, files)
if err != nil {
t.Fatalf("RenderRouteConfig(wildcard+exact, HTTPS=%t) error = %v", https, err)
}
for _, tt := range []struct{ domain, upstream, origin string }{
{domain: "*.s3.example.com", upstream: "backend_wildcard_1", origin: "wildcard-origin:8080"},
{domain: "special.s3.example.com", upstream: "backend_exact_2", origin: "exact-origin:8080"},
} {
upstreamPattern := `(?ms)^upstream ` + regexp.QuoteMeta(tt.upstream) + ` \{.*?^\}`
upstream := regexp.MustCompile(upstreamPattern).FindString(got)
if !strings.Contains(upstream, "server "+tt.origin+" ") {
t.Errorf("upstream %s = %q, want origin %s", tt.upstream, upstream, tt.origin)
}
proxyCount, redirectCount := 0, 0
for _, block := range regexp.MustCompile(`(?ms)^server \{.*?^\}`).FindAllString(got, -1) {
if !strings.Contains(block, "server_name "+tt.domain+";") {
continue
}
if https && strings.Contains(block, "listen 80;") {
redirectCount++
if !strings.Contains(block, "return 301 https://$host$request_uri;") {
t.Errorf("redirect for %s = %q, want actual request host", tt.domain, block)
}
} else {
proxyCount++
if !strings.Contains(block, "proxy_pass http://"+tt.upstream+";") {
t.Errorf("proxy for %s = %q, want upstream %s", tt.domain, block, tt.upstream)
}
}
}
wantRedirects := 0
if https {
wantRedirects = 1
}
if proxyCount != 1 || redirectCount != wantRedirects {
t.Errorf("server blocks for %s: proxy=%d redirect=%d, want proxy=1 redirect=%d", tt.domain, proxyCount, redirectCount, wantRedirects)
}
}
})
}
}
// TestImportedIDNRouteRendering covers the canonical names produced by legacy import.
func TestImportedIDNRouteRendering(t *testing.T) {
domain := "api.xn--bcher-kva.de"
for _, https := range []bool{false, true} {
doc := Document{Routes: []Route{{ID: 1, SiteName: "idn", Domains: []string{domain}, OriginURL: "http://origin:8080", EnableHTTPS: https, DomainCertIDs: []uint{1}}}}
files := []SupportFile{{Path: "1.crt", Content: testCertificatePEMForDomain(t, domain)}}
got, err := RenderRouteConfig(doc, files)
if err != nil {
t.Fatalf("RenderRouteConfig(imported IDN, HTTPS=%t): %v", https, err)
}
if !strings.Contains(got, "server_name "+domain+";") {
t.Errorf("RenderRouteConfig(imported IDN, HTTPS=%t) missing canonical server name", https)
}
}
}