From 9a51a68206c2090e11461345b8b7915caa18db8b Mon Sep 17 00:00:00 2001 From: Deqiying Date: Mon, 9 Nov 2026 10:31:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(domain):=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E9=85=8D=E7=AC=A6=E5=9F=9F=E5=90=8D=E5=8F=8A=E5=85=B3=E8=81=94?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=80=82=E9=85=8D=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- docs/changelog/index.md | 7 + docs/design/cloudflare-pointing.md | 17 +- docs/design/zone-design.md | 16 +- docs/docs.go | 2 + docs/guide/proxy-config.md | 18 +- docs/swagger.json | 2 + docs/swagger.yaml | 2 + .../quick-create-zone-domain-dialog.tsx | 2 +- .../components/resolve-zone-domain-input.ts | 49 +++-- frontend/messages/fragments/websites.en.json | 10 +- frontend/messages/fragments/websites.ru.json | 2 +- .../messages/fragments/websites.zh-CN.json | 10 +- .../cloudflare/member-add-dialog.test.tsx | 34 ++++ .../zone/resolve-zone-domain-input.test.ts | 130 ++++++++++--- .../tests/zone/wildcard-domain-ui.test.tsx | 175 ++++++++++++++++++ .../tests/zone/zone-domain-selector.test.tsx | 24 +++ internal/apps/agent/sync/wildcard_test.go | 84 +++++++++ .../openflare/cloudflare/wildcard_test.go | 135 ++++++++++++++ .../config_version/wildcard_snapshot_test.go | 65 +++++++ .../openflare/integration/security_test.go | 18 +- internal/apps/openflare/uptimekuma/sync.go | 14 +- .../openflare/uptimekuma/wildcard_test.go | 82 ++++++++ internal/apps/openflare/zone/errs.go | 23 ++- internal/apps/openflare/zone/legacy_import.go | 32 +++- .../apps/openflare/zone/legacy_import_test.go | 114 ++++++++++++ internal/apps/openflare/zone/logics.go | 26 ++- internal/apps/openflare/zone/logics_test.go | 94 +++++++++- internal/apps/openflare/zone/routers.go | 4 +- .../openflare/zone/stats_wildcard_test.go | 105 +++++++++++ internal/model/analytics/filter.go | 2 +- internal/model/openflare_observability.go | 11 +- internal/model/openflare_zone.go | 4 +- .../analytics/node_access_log_filter.go | 20 +- .../node_access_log_wildcard_test.go | 132 +++++++++++++ .../logstore/node_access_log_wildcard_test.go | 135 ++++++++++++++ .../repository/logstore/postgres_store.go | 26 ++- pkg/render/openresty/wildcard_test.go | 101 ++++++++++ 37 files changed, 1614 insertions(+), 113 deletions(-) create mode 100644 frontend/tests/zone/wildcard-domain-ui.test.tsx create mode 100644 internal/apps/agent/sync/wildcard_test.go create mode 100644 internal/apps/openflare/cloudflare/wildcard_test.go create mode 100644 internal/apps/openflare/config_version/wildcard_snapshot_test.go create mode 100644 internal/apps/openflare/uptimekuma/wildcard_test.go create mode 100644 internal/apps/openflare/zone/stats_wildcard_test.go create mode 100644 internal/repository/analytics/node_access_log_wildcard_test.go create mode 100644 internal/repository/logstore/node_access_log_wildcard_test.go create mode 100644 pkg/render/openresty/wildcard_test.go diff --git a/docs/changelog/index.md b/docs/changelog/index.md index d5e3be55..73fd6ce6 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -17,8 +17,15 @@ sidebar: false ## [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 默认模式;未支持的协议在启动时明确报错。 - Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。 diff --git a/docs/design/cloudflare-pointing.md b/docs/design/cloudflare-pointing.md index 94ca14c3..cca911f7 100644 --- a/docs/design/cloudflare-pointing.md +++ b/docs/design/cloudflare-pointing.md @@ -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 账号导入 **或** 模块内独立录入(混合来源),加密存储 * 指向分组 CRUD:主节点、备用节点(预留)、分组默认橙云 * 成员管理:以 `zone_domain_id` 为粒度加入/移出;成员级橙云 -* 同步:将每个成员写成 Cloudflare 上 **单条 A 记录** → 当前生效节点 IPv4 +* 同步:节点模式写入 **单条 A 记录** → 当前生效节点 IPv4;自定义模式按分组设置写入 A、AAAA 或 CNAME 记录。 * 触发:手动同步、加入成员、改节点/橙云、节点 IP 变更入队 * 异步任务批量同步;成员同步状态与可读错误 @@ -26,11 +26,18 @@ ### 明确不做(更远或永久) * 多 Cloudflare 账号并行(全局一份连接配置) -* AAAA / 多 A 负载 / CNAME 到节点主机名 -* 管理 MX/TXT/Page Rules 等非本模块 A 记录 +* 节点模式的 IPv6 地址跟随 / 多 A 负载 / CNAME 到节点主机名(自定义 AAAA/CNAME 不受此限制) +* 管理 MX/TXT/Page Rules 等其他记录或规则 * 非 Cloudflare DNS 厂商 * 将 DNS 记录管理并入 Zone 核心模型 +### 通配符域名 + +* 先在 Zone 域名列表登记 `*.example.com` 或 `*.s3.example.com`,再在分组成员选择器中添加;可用域名、成员归属与同步状态沿用具体域名的规则。 +* 节点模式创建 A 记录;需要 AAAA 或 CNAME 时选择自定义模式,分别填写 IPv6 地址或具体目标主机名。 +* Cloudflare Zone 查询仍使用注册根域(例如 `example.com`);DNS 记录的 `name` 保留完整通配符名称。创建、更新、查询与删除共用原有同步路径,不能把 `*.` 去掉后作为记录名。 +* DNS-only / 橙云选项沿用分组默认值及成员覆盖值。DNS 同步成功不代表代理配置已生效,仍需绑定站点路由并发布激活配置;HTTPS 还需匹配的证书。 + ## 与现有能力的关系 | 现有能力 | 关系 | diff --git a/docs/design/zone-design.md b/docs/design/zone-design.md index ef5ad1bb..01208d39 100644 --- a/docs/design/zone-design.md +++ b/docs/design/zone-design.md @@ -10,7 +10,7 @@ * Zone 根域使用 Public Suffix List 解析,例如 `api.example.co.uk` 归属 `example.co.uk`。 * 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 模型本身不新增 DNS 记录、边缘函数、预览子域或租户隔离能力。对外 DNS A 记录的创建/更新由独立的 Cloudflare 指向模块负责,且不改变 Zone / ZoneDomain 表职责。 @@ -43,7 +43,7 @@ erDiagram ### `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`。这使每个明确域名的证书只有一个来源。 @@ -70,13 +70,21 @@ WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚 详情页包含: * 概览:域名、路由和有效证书统计;域名—路由—证书摘要;路由级 WAF 与 Pages 摘要。 -* 域名:明确 FQDN 的列表、证书选择和关联路由;不显示或接受通配符域名。 +* 域名:具体或通配符 FQDN 的列表、证书选择和关联路由;快捷新增支持 `*` 展开为 `*.当前根域`,多级泛域名须输入完整名称。 * 路由:筛选到当前 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)。 @@ -86,6 +94,8 @@ WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚 3. **代码切换**:控制面 API、配置快照、渲染、前端均以 Zone 域名为唯一来源;路由写入仅使用 `zone_domain_ids`。 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 绑定键等)。 diff --git a/docs/docs.go b/docs/docs.go index 875968ff..f85153fa 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -12917,6 +12917,7 @@ const docTemplate = `{ "SessionCookie": [] } ], + "description": "支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。", "consumes": [ "application/json" ], @@ -13044,6 +13045,7 @@ const docTemplate = `{ "SessionCookie": [] } ], + "description": "支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。", "consumes": [ "application/json" ], diff --git a/docs/guide/proxy-config.md b/docs/guide/proxy-config.md index 677f0044..cf69e32d 100644 --- a/docs/guide/proxy-config.md +++ b/docs/guide/proxy-config.md @@ -38,16 +38,28 @@ 证书和源站就绪后,即可创建核心网站代理路由: -1. 进入左侧导航 **「网站管理」->「域名列表」**,点击 **「新增 Zone」**: - * **域名**:输入该站点绑定的域名。 +1. 进入左侧导航 **「网站管理」->「域名列表」**,点击 **「新增 Zone」**,登记注册根域(如 `example.com`,不可填写 `*.example.com`)。进入该 Zone 的域名列表添加具体域名或通配符域名: + * **域名**:输入 `api`、`@`、`*` 或完整名称(如 `*.s3.example.com`)。`*` 展开为 `*.example.com`,不支持 `*.s3` 简写。 * **绑定证书**:选择第一步准备或申请好的证书。 2. 配置请求路由规则:进入 **「规则管理」** 页面,点击 **「新建规则」** 或编辑已有规则: * **规则名称**:输入规则的唯一简易标识(如 `app-portal-route`)。 - * **域名匹配**:填入对应的域名(支持通配符或精确域名,需与上面登记的域名一致)。 + * **域名匹配**:从已登记域名中选择具体或通配符域名,也可通过「快捷新增域名」创建并绑定。 * 在下方 **「反向代理」** 选项卡下,选择 **回源方式** 为「直连上游」。 * **源站选择**:从下拉框中选择第二步创建的源站;或者选择手动输入并填入 `http://10.0.0.20:9000`。 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 标签的输入也会被拒绝。 + --- ## 第四步:发布并生效配置 diff --git a/docs/swagger.json b/docs/swagger.json index 196e048f..2c95050d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -12910,6 +12910,7 @@ "SessionCookie": [] } ], + "description": "支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。", "consumes": [ "application/json" ], @@ -13037,6 +13038,7 @@ "SessionCookie": [] } ], + "description": "支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。", "consumes": [ "application/json" ], diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f9fde686..2a5c3188 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -12195,6 +12195,7 @@ paths: post: consumes: - application/json + description: 支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。 parameters: - description: Zone ID in: path @@ -12273,6 +12274,7 @@ paths: post: consumes: - application/json + description: 支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。 parameters: - description: Zone ID in: path diff --git a/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx b/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx index ebc8a4ee..f412d386 100644 --- a/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx +++ b/frontend/app/(main)/websites/components/quick-create-zone-domain-dialog.tsx @@ -114,7 +114,7 @@ export function QuickCreateZoneDomainDialog({ const schema = z.object({ 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(), }); const form = useForm({ diff --git a/frontend/app/(main)/websites/components/resolve-zone-domain-input.ts b/frontend/app/(main)/websites/components/resolve-zone-domain-input.ts index fa764765..70525f18 100644 --- a/frontend/app/(main)/websites/components/resolve-zone-domain-input.ts +++ b/frontend/app/(main)/websites/components/resolve-zone-domain-input.ts @@ -3,6 +3,7 @@ * * - `@` → apex (zone root itself), e.g. `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` */ export type ZoneDomainInputError = @@ -13,6 +14,15 @@ export type ZoneDomainInputError = | 'invalidSubdomain' | '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( rawInput: string, zoneRoot: string, @@ -26,15 +36,7 @@ export function resolveZoneDomainInput( if (!input) { return { domain: '', error: 'enterDomain' }; } - if (input.includes('*')) { - return { domain: '', error: 'noWildcard' }; - } - if ( - input.includes('://') || - input.includes('/') || - input.includes('?') || - input.includes('#') - ) { + if (/\s/.test(input) || !isDnsName(root)) { return { domain: '', error: 'invalidFormat' }; } @@ -42,17 +44,28 @@ export function resolveZoneDomainInput( return { domain: root }; } - // Short label: no dots → prefix under zone root - if (!input.includes('.')) { - if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(input)) { - return { domain: '', error: 'invalidSubdomain' }; - } - return { domain: `${input}.${root}` }; + // 星号只能作为最左侧完整标签;*.s3 不推断为 Zone 内的相对名称。 + const wildcard = input === '*' || input.startsWith('*.'); + if (input.includes('*') && (!wildcard || input.slice(1).includes('*'))) { + return { domain: '', error: 'noWildcard' }; } - // Full FQDN - if (input === root || input.endsWith(`.${root}`)) { - return { domain: input }; + let domain = input; + if (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' }; diff --git a/frontend/messages/fragments/websites.en.json b/frontend/messages/fragments/websites.en.json index 8936ee83..1e2b4eb1 100644 --- a/frontend/messages/fragments/websites.en.json +++ b/frontend/messages/fragments/websites.en.json @@ -79,21 +79,21 @@ "expiresAt": "Expires", "selectZone": "Select a Zone first", "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", "invalidSubdomain": "Invalid subdomain name format", "mustBelongToZone": "The domain must belong to Zone {root}", "domainAdded": "Domain added", "addFailed": "Add failed", "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", "zoneLabel": "Zone:", "domain": "Domain", - "domainPlaceholderWithZone": "api or @ or api.{domain}", - "domainPlaceholder": "api / @ / full domain", + "domainPlaceholderWithZone": "api / @ / * / *.s3.{domain}", + "domainPlaceholder": "api / @ / * / full domain", "willCreate": "Will create:", - "domainExample": "Example: enter api → api.zone.com; @ → the root itself", + "domainExample": "Example: api → api.zone.com; @ → the root; * → *.zone.com. A wildcard cannot be opened directly; visit a concrete matching hostname.", "certOptional": "Certificate (optional)", "noCert": "No certificate" }, diff --git a/frontend/messages/fragments/websites.ru.json b/frontend/messages/fragments/websites.ru.json index 52908a3f..54e09c3b 100644 --- a/frontend/messages/fragments/websites.ru.json +++ b/frontend/messages/fragments/websites.ru.json @@ -79,7 +79,7 @@ "expiresAt": "Срок действия истекает", "selectZone": "Сначала выберите зону", "enterDomain": "Введите домен", - "noWildcard": "Домены зон не поддерживают подстановочные знаки", + "noWildcard": "Допустим только один символ * как крайняя левая метка, например *.example.com", "invalidFormat": "Неверный формат домена", "invalidSubdomain": "Неверный формат имени субдомена", "mustBelongToZone": "Домен должен принадлежать зоне {root}", diff --git a/frontend/messages/fragments/websites.zh-CN.json b/frontend/messages/fragments/websites.zh-CN.json index bd4b4b4e..7db737e9 100644 --- a/frontend/messages/fragments/websites.zh-CN.json +++ b/frontend/messages/fragments/websites.zh-CN.json @@ -79,21 +79,21 @@ "expiresAt": "到期时间", "selectZone": "请先选择 Zone", "enterDomain": "请输入域名", - "noWildcard": "Zone 域名不支持通配符", + "noWildcard": "通配符只能是最左侧的完整标签 *,例如 *.example.com,且只能出现一次", "invalidFormat": "域名格式不合法", "invalidSubdomain": "子域名称格式不合法", "mustBelongToZone": "域名必须属于 Zone {root}", "domainAdded": "域名已添加", "addFailed": "添加失败", "quickCreateTitle": "快捷新增域名", - "quickCreateDesc": "选择 Zone 后输入:子域名称(如 api)、@(根域)或完整 FQDN。", + "quickCreateDesc": "输入子域名称(api)、@(根域)、*(根域下泛域名)或完整 FQDN。多级泛域名请填写完整名称,例如 *.s3.example.com,不支持 *.s3 简写。", "selectRegisteredRoot": "选择注册根域", "zoneLabel": "Zone:", "domain": "域名", - "domainPlaceholderWithZone": "api 或 @ 或 api.{domain}", - "domainPlaceholder": "api / @ / 完整域名", + "domainPlaceholderWithZone": "api / @ / * / *.s3.{domain}", + "domainPlaceholder": "api / @ / * / 完整域名", "willCreate": "将创建:", - "domainExample": "示例:输入 api → api.zone.com;@ → 根域本身", + "domainExample": "示例:api → api.zone.com;@ → 根域;* → *.zone.com。泛域名不能直接打开,请使用匹配的具体主机名访问。", "certOptional": "证书(可选)", "noCert": "不绑定证书" }, diff --git a/frontend/tests/cloudflare/member-add-dialog.test.tsx b/frontend/tests/cloudflare/member-add-dialog.test.tsx index 594e700d..81cd58f2 100644 --- a/frontend/tests/cloudflare/member-add-dialog.test.tsx +++ b/frontend/tests/cloudflare/member-add-dialog.test.tsx @@ -150,4 +150,38 @@ describe('MemberAddDialog advanced domain picker', () => { fireEvent.click(screen.getByRole('button', { name: '添加并同步' })); expect(onSubmit).toHaveBeenCalledWith([2], false); }); + + it('searches and selects a wildcard domain without treating it as a URL', () => { + const onSubmit = vi.fn(); + render( + + + , + ); + + 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(); + }); }); diff --git a/frontend/tests/zone/resolve-zone-domain-input.test.ts b/frontend/tests/zone/resolve-zone-domain-input.test.ts index a5c6d147..bb3cb7d7 100644 --- a/frontend/tests/zone/resolve-zone-domain-input.test.ts +++ b/frontend/tests/zone/resolve-zone-domain-input.test.ts @@ -1,34 +1,118 @@ 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', () => { - it('maps short label and apex @ under zone root', () => { - expect(resolveZoneDomainInput('api', 'example.com')).toEqual({ - domain: 'api.example.com', - }); - expect(resolveZoneDomainInput('@', 'example.com')).toEqual({ - domain: 'example.com', + it.each([ + ['api', '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); + }); + + 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', () => { - expect( - resolveZoneDomainInput('www.api.example.com', 'example.com'), - ).toEqual({ - domain: 'www.api.example.com', - }); - expect(resolveZoneDomainInput('example.com', 'example.com')).toEqual({ - domain: 'example.com', - }); + it.each([ + '-api', + 'api-', + '_api', + '-api.example.com', + 'api-.example.com', + '_api.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', () => { - expect( - resolveZoneDomainInput('evil.com', 'example.com').error, - ).toBeTruthy(); - expect( - resolveZoneDomainInput('*.example.com', 'example.com').error, - ).toBeTruthy(); + it('checks total length after expanding shorthand', () => { + const longRoot = `${'a'.repeat(63)}.${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(61)}`; + expect(longRoot).toHaveLength(253); + expect(resolveZoneDomainInput('@', longRoot)).toEqual({ domain: longRoot }); + expect(resolveZoneDomainInput('*', longRoot).error).toBe('invalidFormat'); + expect(resolveZoneDomainInput('api', longRoot).error).toBe('invalidFormat'); + }); + + 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', + }); }); }); diff --git a/frontend/tests/zone/wildcard-domain-ui.test.tsx b/frontend/tests/zone/wildcard-domain-ui.test.tsx new file mode 100644 index 00000000..c9b5cc2e --- /dev/null +++ b/frontend/tests/zone/wildcard-domain-ui.test.tsx @@ -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(); + 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( + + {ui} + , + ); +} + +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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getByText('*.example.com')).toBeVisible(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/zone/zone-domain-selector.test.tsx b/frontend/tests/zone/zone-domain-selector.test.tsx index 7fa082d0..2cb52908 100644 --- a/frontend/tests/zone/zone-domain-selector.test.tsx +++ b/frontend/tests/zone/zone-domain-selector.test.tsx @@ -110,4 +110,28 @@ describe('ZoneDomainSelector', () => { expect(screen.getByText('bound.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( + , + ); + + 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('*'); + } + }); }); diff --git a/internal/apps/agent/sync/wildcard_test.go b/internal/apps/agent/sync/wildcard_test.go new file mode 100644 index 00000000..a617e385 --- /dev/null +++ b/internal/apps/agent/sync/wildcard_test.go @@ -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) + } + }) + } +} diff --git a/internal/apps/openflare/cloudflare/wildcard_test.go b/internal/apps/openflare/cloudflare/wildcard_test.go new file mode 100644 index 00000000..69e7194e --- /dev/null +++ b/internal/apps/openflare/cloudflare/wildcard_test.go @@ -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()) + } + }) + } +} diff --git a/internal/apps/openflare/config_version/wildcard_snapshot_test.go b/internal/apps/openflare/config_version/wildcard_snapshot_test.go new file mode 100644 index 00000000..3c5dc885 --- /dev/null +++ b/internal/apps/openflare/config_version/wildcard_snapshot_test.go @@ -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) + } +} diff --git a/internal/apps/openflare/integration/security_test.go b/internal/apps/openflare/integration/security_test.go index 6f9ad370..afe19cc2 100644 --- a/internal/apps/openflare/integration/security_test.go +++ b/internal/apps/openflare/integration/security_test.go @@ -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{ "domain": "*.example.com", }, adminAuthHeaders(seed.Token)) - require.Equal(t, http.StatusBadRequest, rec.Code) - errResp := decodeAPIResponse(t, rec) - assert.NotEmpty(t, errResp.ErrorMsg) + if rec.Code != http.StatusOK { + t.Fatalf("create wildcard domain status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + 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{ "domain": "security.example.com", "cert_id": certID, diff --git a/internal/apps/openflare/uptimekuma/sync.go b/internal/apps/openflare/uptimekuma/sync.go index e33e00d5..c6e25003 100644 --- a/internal/apps/openflare/uptimekuma/sync.go +++ b/internal/apps/openflare/uptimekuma/sync.go @@ -229,11 +229,17 @@ func routeMonitorURL(ctx context.Context, route *model.ProxyRoute) (string, erro if len(domains) == 0 { return "", fmt.Errorf("route %s has no zone domains", route.SiteName) } - domain := domains[0].Domain - if route.EnableHTTPS { - return "https://" + domain, nil + // 通配符是匹配规则而非可探测地址;混合绑定时选择具体域名。 + for _, domain := range domains { + if strings.Contains(domain.Domain, "*") { + continue + } + if route.EnableHTTPS { + return "https://" + domain.Domain, nil + } + return "http://" + domain.Domain, nil } - return "http://" + domain, nil + return "", fmt.Errorf("route %s only has wildcard domains; bind a concrete hostname for monitoring", route.SiteName) } func monitorPayload(id int, name, targetURL string, config *kumaConfig) map[string]any { diff --git a/internal/apps/openflare/uptimekuma/wildcard_test.go b/internal/apps/openflare/uptimekuma/wildcard_test.go new file mode 100644 index 00000000..6cc2ecd6 --- /dev/null +++ b/internal/apps/openflare/uptimekuma/wildcard_test.go @@ -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") + } +} diff --git a/internal/apps/openflare/zone/errs.go b/internal/apps/openflare/zone/errs.go index 5fef50f7..60d72b59 100644 --- a/internal/apps/openflare/zone/errs.go +++ b/internal/apps/openflare/zone/errs.go @@ -4,16 +4,15 @@ package zone const ( - errZoneDomainRequired = "domain name must not be empty" - errZoneRootInvalid = "zone must be a valid registered root domain" - errDomainInvalid = "invalid domain name format" - errDomainWildcardUnsupported = "wildcard domain names are not supported" - errDomainOutsideZone = "domain does not belong to this zone" - errZoneNotFound = "Zone was not found" - errDomainNotFound = "Domain was not found" - errDomainExists = "domain already exists" - errCertificateNotFound = "selected certificate was not found" - errDomainBoundToRoute = "domain is bound to a proxy route; remove the binding first" - errZoneHasDomains = "root domain still contains domains; delete them all first" - errStatsRangeInvalid = "invalid time range; select 24h, 7d or 30d" + errZoneDomainRequired = "domain name must not be empty" + errZoneRootInvalid = "zone must be a valid registered root domain" + errDomainInvalid = "invalid domain name format" + errDomainOutsideZone = "domain does not belong to this zone" + errZoneNotFound = "Zone was not found" + errDomainNotFound = "Domain was not found" + errDomainExists = "domain already exists" + errCertificateNotFound = "selected certificate was not found" + errDomainBoundToRoute = "domain is bound to a proxy route; remove the binding first" + errZoneHasDomains = "root domain still contains domains; delete them all first" + errStatsRangeInvalid = "invalid time range; select 24h, 7d or 30d" ) diff --git a/internal/apps/openflare/zone/legacy_import.go b/internal/apps/openflare/zone/legacy_import.go index 30d6fe84..3e261012 100644 --- a/internal/apps/openflare/zone/legacy_import.go +++ b/internal/apps/openflare/zone/legacy_import.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity" + "golang.org/x/net/idna" ) // 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 { - domain, normErr := normalizeDomain(item.Domain) + domain, normErr := normalizeLegacyDomain(item.Domain) if normErr != nil { report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", item.Domain, normErr)) continue @@ -95,22 +96,28 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo var existingID uint var existingZoneDomain string + var existingRouteID, existingCertID sql.Null[uint] 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 JOIN of_zones z ON z.id = zd.zone_id WHERE zd.domain = ? - `), domain).Scan(&existingID, &existingZoneDomain) + `), domain).Scan(&existingID, &existingZoneDomain, &existingRouteID, &existingCertID) 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 { 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(` UPDATE of_zone_domains SET proxy_route_id = COALESCE(proxy_route_id, ?), cert_id = COALESCE(cert_id, ?) WHERE id = ? - `), *item.ProxyRouteID, nullableUint(item.CertID), existingID); bindErr != nil { + `), nullableUint(item.ProxyRouteID), nullableUint(item.CertID), existingID); bindErr != nil { return report, bindErr } } @@ -152,6 +159,21 @@ func ImportLegacyTx(ctx context.Context, tx *sql.Tx, postgres bool) (report Impo 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( ctx context.Context, tx *sql.Tx, diff --git a/internal/apps/openflare/zone/legacy_import_test.go b/internal/apps/openflare/zone/legacy_import_test.go index c15071d5..64127d70 100644 --- a/internal/apps/openflare/zone/legacy_import_test.go +++ b/internal/apps/openflare/zone/legacy_import_test.go @@ -148,3 +148,117 @@ func TestImportLegacyTxNoOpWithoutLegacyColumns(t *testing.T) { assert.Equal(t, 0, report.Zones) 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) + } +} diff --git a/internal/apps/openflare/zone/logics.go b/internal/apps/openflare/zone/logics.go index 34b27517..309ba44a 100644 --- a/internal/apps/openflare/zone/logics.go +++ b/internal/apps/openflare/zone/logics.go @@ -1,7 +1,7 @@ // Copyright 2026 Arctel.net // 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 import ( @@ -18,6 +18,8 @@ import ( "gorm.io/gorm" ) +const maxDomainLength = 253 + // Input is the mutable Zone payload. type Input struct { Domain string `json:"domain"` @@ -45,7 +47,8 @@ type ListItem struct { } 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) { @@ -53,12 +56,21 @@ func normalizeDomain(raw string) (string, error) { if domain == "" { return "", errors.New(errZoneDomainRequired) } - if strings.Contains(domain, "*") { - return "", errors.New(errDomainWildcardUnsupported) - } - if strings.Contains(domain, "://") || strings.Contains(domain, "/") || strings.Contains(domain, "?") || strings.Contains(domain, "#") || strings.Contains(domain, "@") { + // 只允许最左侧完整标签为通配符;其余字符必须是 DNS 标签,不能进入配置语法。 + host := strings.TrimPrefix(domain, "*.") + if len(domain) > maxDomainLength { 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 { return "", errors.New(errDomainInvalid) } @@ -151,7 +163,7 @@ func GetOverview(ctx context.Context, id uint) (*Overview, error) { 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) { zone, err := repository.GetZoneByID(ctx, zoneID) if err != nil { diff --git a/internal/apps/openflare/zone/logics_test.go b/internal/apps/openflare/zone/logics_test.go index 57b9e465..dfdc88ea 100644 --- a/internal/apps/openflare/zone/logics_test.go +++ b/internal/apps/openflare/zone/logics_test.go @@ -5,6 +5,7 @@ package zone import ( "context" + "strings" "testing" "time" @@ -28,12 +29,97 @@ func setupZoneDB(t *testing.T) context.Context { 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) zone, err := Create(ctx, Input{Domain: "example.com"}) - require.NoError(t, err) - _, err = CreateDomain(ctx, zone.ID, DomainInput{Domain: "*.example.com"}) - require.EqualError(t, err, errDomainWildcardUnsupported) + if err != nil { + t.Fatal(err) + } + 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) { diff --git a/internal/apps/openflare/zone/routers.go b/internal/apps/openflare/zone/routers.go index 4201ee36..69c78661 100644 --- a/internal/apps/openflare/zone/routers.go +++ b/internal/apps/openflare/zone/routers.go @@ -161,8 +161,9 @@ func DeleteHandler(c *gin.Context) { 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 域名 +// @Description 支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。 // @Tags openflare-zone // @Accept json // @Produce json @@ -192,6 +193,7 @@ func CreateDomainHandler(c *gin.Context) { // UpdateDomainHandler updates a Zone domain. // @Summary 更新 Zone 域名 +// @Description 支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。 // @Tags openflare-zone // @Accept json // @Produce json diff --git a/internal/apps/openflare/zone/stats_wildcard_test.go b/internal/apps/openflare/zone/stats_wildcard_test.go new file mode 100644 index 00000000..21d53367 --- /dev/null +++ b/internal/apps/openflare/zone/stats_wildcard_test.go @@ -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) + } + }) + } +} diff --git a/internal/model/analytics/filter.go b/internal/model/analytics/filter.go index f0a4c886..9498fc75 100644 --- a/internal/model/analytics/filter.go +++ b/internal/model/analytics/filter.go @@ -24,7 +24,7 @@ type NodeAccessLogFilter struct { NodeID string RemoteAddr string Host string - // Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes. + // Hosts 优先于 Host:具体域名精确匹配(忽略大小写),*.suffix 匹配非空、多层子域名但不含 suffix 本身。 Hosts []string Path string // StatusCode filters by exact HTTP status code when > 0. diff --git a/internal/model/openflare_observability.go b/internal/model/openflare_observability.go index 59d325a8..55974110 100644 --- a/internal/model/openflare_observability.go +++ b/internal/model/openflare_observability.go @@ -160,7 +160,9 @@ type OpenFlareAccessLogQuery struct { NodeID string RemoteAddr 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 Path string // StatusCode filters by exact HTTP status code when > 0. @@ -175,9 +177,10 @@ type OpenFlareAccessLogQuery struct { // OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub). type OpenFlareAccessLogBucketQuery struct { - NodeID string - RemoteAddr string - Host string + NodeID string + RemoteAddr string + Host string + // Hosts follows OpenFlareAccessLogQuery.Hosts exact and wildcard matching. Hosts []string Path string Since time.Time diff --git a/internal/model/openflare_zone.go b/internal/model/openflare_zone.go index aff02dce..4d6ca455 100644 --- a/internal/model/openflare_zone.go +++ b/internal/model/openflare_zone.go @@ -25,7 +25,7 @@ func (Zone) TableName() string { return tableOfZones } -// ZoneDomain OpenFlare Zone 下的明确域名实体。 +// ZoneDomain OpenFlare Zone 下的具体或通配符域名实体。 type ZoneDomain struct { ID uint `json:"id" gorm:"primaryKey;autoIncrement"` 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 } -// ZoneDomainCount is the per-zone explicit domain count for list queries. +// ZoneDomainCount 是每个 Zone 的登记域名记录数,通配符按一条记录计数。 type ZoneDomainCount struct { ZoneID uint `json:"zone_id" gorm:"column:zone_id"` Count int64 `json:"count" gorm:"column:count"` diff --git a/internal/repository/analytics/node_access_log_filter.go b/internal/repository/analytics/node_access_log_filter.go index fc1f71c8..64dd05ee 100644 --- a/internal/repository/analytics/node_access_log_filter.go +++ b/internal/repository/analytics/node_access_log_filter.go @@ -42,12 +42,28 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any) } hosts := normalizeNodeAccessLogHosts(filter.Hosts) if len(hosts) > 0 { + hostParts := make([]string, 0, len(hosts)) placeholders := make([]string, 0, len(hosts)) + var exactHosts []any for _, host := range hosts { + if suffix, wildcard := strings.CutPrefix(host, "*."); wildcard && suffix != "" { + // _% 要求非空子域名,并保留 Nginx 通配符可匹配多层子域名的语义。 + hostParts = append(hostParts, "(lowerUTF8(trim(host)) LIKE ? AND trim(host) NOT LIKE '%..%' AND trim(host) NOT LIKE '.%')") + args = append(args, "_%."+util.EscapeLike(suffix)) + continue + } placeholders = append(placeholders, "?") - args = append(args, host) + exactHosts = append(exactHosts, host) } - parts = append(parts, "lowerUTF8(trim(host)) IN ("+strings.Join(placeholders, ", ")+")") + if len(exactHosts) > 0 { + hostParts = append(hostParts, "lowerUTF8(trim(host)) IN ("+strings.Join(placeholders, ", ")+")") + args = append(args, exactHosts...) + } + hostClause := strings.Join(hostParts, " OR ") + if len(hostParts) > 1 { + hostClause = "(" + hostClause + ")" + } + parts = append(parts, hostClause) } else if trimmed := strings.TrimSpace(filter.Host); trimmed != "" { parts = append(parts, "host LIKE ?") args = append(args, util.EscapeLike(trimmed)+"%") diff --git a/internal/repository/analytics/node_access_log_wildcard_test.go b/internal/repository/analytics/node_access_log_wildcard_test.go new file mode 100644 index 00000000..39313424 --- /dev/null +++ b/internal/repository/analytics/node_access_log_wildcard_test.go @@ -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) + } + } +} diff --git a/internal/repository/logstore/node_access_log_wildcard_test.go b/internal/repository/logstore/node_access_log_wildcard_test.go new file mode 100644 index 00000000..276e97d9 --- /dev/null +++ b/internal/repository/logstore/node_access_log_wildcard_test.go @@ -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)) + } + }) + } +} diff --git a/internal/repository/logstore/postgres_store.go b/internal/repository/logstore/postgres_store.go index 0b68ecf6..01dd5846 100644 --- a/internal/repository/logstore/postgres_store.go +++ b/internal/repository/logstore/postgres_store.go @@ -814,7 +814,7 @@ func toNodeAccessLogFilter(query model.OpenFlareAccessLogQuery) analyticsmodel.N } // buildNodeAccessLogFilterParts 拼装节点访问日志过滤条件(node_id 等值、remote_addr/host/path -// 前缀 LIKE、hosts 走 lower(trim(host)) IN、since/until 时间窗,顺序与 applyNodeAccessLogFilter +// 前缀 LIKE、hosts 精确域名或通配符后缀、since/until 时间窗,顺序与 applyNodeAccessLogFilter // 完全一致),返回 WHERE 片段与参数;无任何条件时返回空串与 nil。 func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string, []any) { var parts []string @@ -829,8 +829,26 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string } hosts := normalizeNodeAccessLogHosts(f.Hosts) if len(hosts) > 0 { - parts = append(parts, "lower(trim(host)) IN ?") - args = append(args, hosts) + var hostParts []string + 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 != "" { parts = append(parts, `host LIKE ? ESCAPE '\'`) args = append(args, util.EscapeLike(host)+"%") @@ -858,7 +876,7 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string } // 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 开区间 <。 func applyNodeAccessLogFilter(q *gorm.DB, f analyticsmodel.NodeAccessLogFilter) *gorm.DB { cond, args := buildNodeAccessLogFilterParts(f) diff --git a/pkg/render/openresty/wildcard_test.go b/pkg/render/openresty/wildcard_test.go new file mode 100644 index 00000000..60262d73 --- /dev/null +++ b/pkg/render/openresty/wildcard_test.go @@ -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) + } + } +}