merge: zone domain foundation

This commit is contained in:
ryan
2026-07-12 14:41:04 +08:00
23 changed files with 1824 additions and 965 deletions
+16
View File
@@ -0,0 +1,16 @@
# Task 2 report — Zone domain and API
## Delivered
- Added the `internal/apps/openflare/zone` domain layer. Zone roots and explicit hostnames are normalized with `publicsuffix.EffectiveTLDPlusOne`; root creation requires exact eTLD+1 equality and hostnames must belong to the selected Zone.
- Wildcards, empty values, protocols, paths, query/fragment/userinfo forms are rejected. A supplied certificate is checked before persistence.
- Added `migrate-zones`, an explicit transactional and idempotent importer. It reads route domains through `routeidentity.DecodeDomains`, aligns legacy `domain_cert_ids` by index, only uses `of_managed_domains` when no route domains exist, and rolls back with all discovered conflicts.
- Registered authenticated `/api/v1/d/zones` list/create, `/:id/overview`, and `/:id/domains` create endpoints. Retired the `managed-domains` route block and its Swagger paths, while preserving the legacy model/table and logic for migration compatibility.
- Added focused TDD tests for wildcard rejection and PSL handling, plus authenticated integration coverage for Zone/domain creation and the 400 response envelope.
## Verification
- `go test ./internal/apps/openflare/zone ./internal/apps/openflare/integration -count=1`
- `make code-check`
- `make swagger` (the generator prints pre-existing octal-constant evaluation warnings; generation succeeds)
- Confirmed generated Swagger contains `/zones` and no `/managed-domains` paths.
+5
View File
@@ -21,6 +21,11 @@ sidebar: false
## [unreleased]
### 新增
- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。
- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。
### 修复
- Docker ClickHouse 性能配置改为单文件挂载,避免覆盖镜像内置的 Docker 网络监听配置,导致宿主机无法通过 8123/9000 访问服务。
+353 -348
View File
@@ -1884,7 +1884,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
"description": "返回 ClickHouse parts、mutation、async_insert 队列等运维指标,需要管理员权限",
"description": "返回 ClickHouse parts、mutation、async_insert 队列及进程内 batch writer 指标,需要管理员权限",
"produces": [
"application/json"
],
@@ -6336,353 +6336,6 @@ const docTemplate = `{
}
}
},
"/api/v1/d/managed-domains": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回全部托管域名及关联证书,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "列出托管域名",
"responses": {
"200": {
"description": "托管域名列表",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/model.ManagedDomain"
}
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
},
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "创建新的托管域名记录,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "创建托管域名",
"parameters": [
{
"description": "托管域名参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/tls.ManagedDomainInput"
}
}
],
"responses": {
"200": {
"description": "创建成功的托管域名",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.ManagedDomain"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/managed-domains/match": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "按域名查询可用的证书匹配候选,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "匹配托管域名证书",
"parameters": [
{
"type": "string",
"description": "域名",
"name": "domain",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "证书匹配结果",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/tls.ManagedDomainMatchResult"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/managed-domains/{id}/delete": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "按 ID 删除托管域名,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "删除托管域名",
"parameters": [
{
"type": "integer",
"description": "托管域名 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "删除成功",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "记录不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/managed-domains/{id}/update": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "按 ID 更新托管域名,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "更新托管域名",
"parameters": [
{
"type": "integer",
"description": "托管域名 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "托管域名参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/tls.ManagedDomainInput"
}
}
],
"responses": {
"200": {
"description": "更新后的托管域名",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.ManagedDomain"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "记录不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes": {
"get": {
"security": [
@@ -11168,6 +10821,231 @@ const docTemplate = `{
}
}
},
"/api/v1/d/zones": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"produces": [
"application/json"
],
"tags": [
"openflare-zone"
],
"summary": "获取 Zone 列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/model.Zone"
}
}
}
}
]
}
}
}
},
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-zone"
],
"summary": "创建 Zone",
"parameters": [
{
"description": "Zone 参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/zone.Input"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.Zone"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/zones/{id}/domains": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-zone"
],
"summary": "创建 Zone 域名",
"parameters": [
{
"type": "integer",
"description": "Zone ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "域名参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/zone.DomainInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.ZoneDomain"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/zones/{id}/overview": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"produces": [
"application/json"
],
"tags": [
"openflare-zone"
],
"summary": "获取 Zone 概览",
"parameters": [
{
"type": "integer",
"description": "Zone ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/zone.Overview"
}
}
}
]
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/oauth/callback": {
"post": {
"description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。",
@@ -13119,6 +12997,13 @@ const docTemplate = `{
"async_insert_queue": {
"type": "integer"
},
"batch_writers": {
"description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.",
"type": "array",
"items": {
"$ref": "#/definitions/batchwriter.Stats"
}
},
"database": {
"type": "string"
},
@@ -13218,6 +13103,29 @@ const docTemplate = `{
}
}
},
"batchwriter.Stats": {
"type": "object",
"properties": {
"cap": {
"type": "integer"
},
"depth": {
"type": "integer"
},
"drops": {
"type": "integer"
},
"flush_errors": {
"type": "integer"
},
"name": {
"type": "string"
},
"running": {
"type": "boolean"
}
}
},
"cache.updateCacheConfigRequest": {
"type": "object",
"required": [
@@ -15288,6 +15196,55 @@ const docTemplate = `{
"UploadStatusDeleted"
]
},
"model.Zone": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"domain": {
"type": "string"
},
"id": {
"type": "integer"
},
"remark": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"model.ZoneDomain": {
"type": "object",
"properties": {
"cert_id": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"domain": {
"type": "string"
},
"id": {
"type": "integer"
},
"proxy_route_id": {
"type": "integer"
},
"remark": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"zone_id": {
"type": "integer"
}
}
},
"node.AgentReleaseInfo": {
"type": "object",
"properties": {
@@ -16263,15 +16220,24 @@ const docTemplate = `{
"option.databaseCleanupResult": {
"type": "object",
"properties": {
"cleanup_mode": {
"type": "string"
},
"delete_all": {
"type": "boolean"
},
"deleted_count": {
"type": "integer"
},
"eligible_count": {
"type": "integer"
},
"retention_days": {
"type": "integer"
},
"table_ttl_days": {
"type": "integer"
},
"target": {
"type": "string"
},
@@ -18807,6 +18773,45 @@ const docTemplate = `{
}
}
}
},
"zone.DomainInput": {
"type": "object",
"properties": {
"cert_id": {
"type": "integer"
},
"domain": {
"type": "string"
},
"remark": {
"type": "string"
}
}
},
"zone.Input": {
"type": "object",
"properties": {
"domain": {
"type": "string"
},
"remark": {
"type": "string"
}
}
},
"zone.Overview": {
"type": "object",
"properties": {
"domains": {
"type": "array",
"items": {
"$ref": "#/definitions/model.ZoneDomain"
}
},
"zone": {
"$ref": "#/definitions/model.Zone"
}
}
}
},
"securityDefinitions": {
+353 -348
View File
@@ -1877,7 +1877,7 @@
"SessionCookie": []
}
],
"description": "返回 ClickHouse parts、mutation、async_insert 队列等运维指标,需要管理员权限",
"description": "返回 ClickHouse parts、mutation、async_insert 队列及进程内 batch writer 指标,需要管理员权限",
"produces": [
"application/json"
],
@@ -6329,353 +6329,6 @@
}
}
},
"/api/v1/d/managed-domains": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回全部托管域名及关联证书,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "列出托管域名",
"responses": {
"200": {
"description": "托管域名列表",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/model.ManagedDomain"
}
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
},
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "创建新的托管域名记录,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "创建托管域名",
"parameters": [
{
"description": "托管域名参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/tls.ManagedDomainInput"
}
}
],
"responses": {
"200": {
"description": "创建成功的托管域名",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.ManagedDomain"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/managed-domains/match": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "按域名查询可用的证书匹配候选,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "匹配托管域名证书",
"parameters": [
{
"type": "string",
"description": "域名",
"name": "domain",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "证书匹配结果",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/tls.ManagedDomainMatchResult"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/managed-domains/{id}/delete": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "按 ID 删除托管域名,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "删除托管域名",
"parameters": [
{
"type": "integer",
"description": "托管域名 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "删除成功",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "记录不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/managed-domains/{id}/update": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "按 ID 更新托管域名,需要管理员权限",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-tls"
],
"summary": "更新托管域名",
"parameters": [
{
"type": "integer",
"description": "托管域名 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "托管域名参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/tls.ManagedDomainInput"
}
}
],
"responses": {
"200": {
"description": "更新后的托管域名",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.ManagedDomain"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "记录不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes": {
"get": {
"security": [
@@ -11161,6 +10814,231 @@
}
}
},
"/api/v1/d/zones": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"produces": [
"application/json"
],
"tags": [
"openflare-zone"
],
"summary": "获取 Zone 列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/model.Zone"
}
}
}
}
]
}
}
}
},
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-zone"
],
"summary": "创建 Zone",
"parameters": [
{
"description": "Zone 参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/zone.Input"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.Zone"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/zones/{id}/domains": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-zone"
],
"summary": "创建 Zone 域名",
"parameters": [
{
"type": "integer",
"description": "Zone ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "域名参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/zone.DomainInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/model.ZoneDomain"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/zones/{id}/overview": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"produces": [
"application/json"
],
"tags": [
"openflare-zone"
],
"summary": "获取 Zone 概览",
"parameters": [
{
"type": "integer",
"description": "Zone ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/zone.Overview"
}
}
}
]
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/oauth/callback": {
"post": {
"description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。",
@@ -13112,6 +12990,13 @@
"async_insert_queue": {
"type": "integer"
},
"batch_writers": {
"description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.",
"type": "array",
"items": {
"$ref": "#/definitions/batchwriter.Stats"
}
},
"database": {
"type": "string"
},
@@ -13211,6 +13096,29 @@
}
}
},
"batchwriter.Stats": {
"type": "object",
"properties": {
"cap": {
"type": "integer"
},
"depth": {
"type": "integer"
},
"drops": {
"type": "integer"
},
"flush_errors": {
"type": "integer"
},
"name": {
"type": "string"
},
"running": {
"type": "boolean"
}
}
},
"cache.updateCacheConfigRequest": {
"type": "object",
"required": [
@@ -15281,6 +15189,55 @@
"UploadStatusDeleted"
]
},
"model.Zone": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"domain": {
"type": "string"
},
"id": {
"type": "integer"
},
"remark": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"model.ZoneDomain": {
"type": "object",
"properties": {
"cert_id": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"domain": {
"type": "string"
},
"id": {
"type": "integer"
},
"proxy_route_id": {
"type": "integer"
},
"remark": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"zone_id": {
"type": "integer"
}
}
},
"node.AgentReleaseInfo": {
"type": "object",
"properties": {
@@ -16256,15 +16213,24 @@
"option.databaseCleanupResult": {
"type": "object",
"properties": {
"cleanup_mode": {
"type": "string"
},
"delete_all": {
"type": "boolean"
},
"deleted_count": {
"type": "integer"
},
"eligible_count": {
"type": "integer"
},
"retention_days": {
"type": "integer"
},
"table_ttl_days": {
"type": "integer"
},
"target": {
"type": "string"
},
@@ -18800,6 +18766,45 @@
}
}
}
},
"zone.DomainInput": {
"type": "object",
"properties": {
"cert_id": {
"type": "integer"
},
"domain": {
"type": "string"
},
"remark": {
"type": "string"
}
}
},
"zone.Input": {
"type": "object",
"properties": {
"domain": {
"type": "string"
},
"remark": {
"type": "string"
}
}
},
"zone.Overview": {
"type": "object",
"properties": {
"domains": {
"type": "array",
"items": {
"$ref": "#/definitions/model.ZoneDomain"
}
},
"zone": {
"$ref": "#/definitions/model.Zone"
}
}
}
},
"securityDefinitions": {
+215 -212
View File
@@ -177,6 +177,12 @@ definitions:
type: integer
async_insert_queue:
type: integer
batch_writers:
description: BatchWriters reports in-process queue depth/drops/flush errors
for CH writers.
items:
$ref: '#/definitions/batchwriter.Stats'
type: array
database:
type: string
pending_mutations:
@@ -241,6 +247,21 @@ definitions:
is_active:
type: boolean
type: object
batchwriter.Stats:
properties:
cap:
type: integer
depth:
type: integer
drops:
type: integer
flush_errors:
type: integer
name:
type: string
running:
type: boolean
type: object
cache.updateCacheConfigRequest:
properties:
lru_enabled:
@@ -1619,6 +1640,38 @@ definitions:
- UploadStatusPending
- UploadStatusUsed
- UploadStatusDeleted
model.Zone:
properties:
created_at:
type: string
domain:
type: string
id:
type: integer
remark:
type: string
updated_at:
type: string
type: object
model.ZoneDomain:
properties:
cert_id:
type: integer
created_at:
type: string
domain:
type: string
id:
type: integer
proxy_route_id:
type: integer
remark:
type: string
updated_at:
type: string
zone_id:
type: integer
type: object
node.AgentReleaseInfo:
properties:
body:
@@ -2257,12 +2310,18 @@ definitions:
type: object
option.databaseCleanupResult:
properties:
cleanup_mode:
type: string
delete_all:
type: boolean
deleted_count:
type: integer
eligible_count:
type: integer
retention_days:
type: integer
table_ttl_days:
type: integer
target:
type: string
target_label:
@@ -3951,6 +4010,31 @@ definitions:
$ref: '#/definitions/waf.RuleGroupView'
type: array
type: object
zone.DomainInput:
properties:
cert_id:
type: integer
domain:
type: string
remark:
type: string
type: object
zone.Input:
properties:
domain:
type: string
remark:
type: string
type: object
zone.Overview:
properties:
domains:
items:
$ref: '#/definitions/model.ZoneDomain'
type: array
zone:
$ref: '#/definitions/model.Zone'
type: object
info:
contact:
name: OpenFlare
@@ -5062,7 +5146,7 @@ paths:
- admin
/api/v1/admin/status/clickhouse:
get:
description: 返回 ClickHouse parts、mutation、async_insert 队列等运维指标,需要管理员权限
description: 返回 ClickHouse parts、mutation、async_insert 队列及进程内 batch writer 指标,需要管理员权限
produces:
- application/json
responses:
@@ -7750,217 +7834,6 @@ paths:
summary: 更新 DNS 账号
tags:
- openflare-tls
/api/v1/d/managed-domains:
get:
description: 返回全部托管域名及关联证书,需要管理员权限
produces:
- application/json
responses:
"200":
description: 托管域名列表
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
items:
$ref: '#/definitions/model.ManagedDomain'
type: array
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限或不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 内部错误
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 列出托管域名
tags:
- openflare-tls
post:
consumes:
- application/json
description: 创建新的托管域名记录,需要管理员权限
parameters:
- description: 托管域名参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/tls.ManagedDomainInput'
produces:
- application/json
responses:
"200":
description: 创建成功的托管域名
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/model.ManagedDomain'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限或不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 内部错误
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 创建托管域名
tags:
- openflare-tls
/api/v1/d/managed-domains/{id}/delete:
post:
description: 按 ID 删除托管域名,需要管理员权限
parameters:
- description: 托管域名 ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 删除成功
schema:
$ref: '#/definitions/response.Any'
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 记录不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 内部错误
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 删除托管域名
tags:
- openflare-tls
/api/v1/d/managed-domains/{id}/update:
post:
consumes:
- application/json
description: 按 ID 更新托管域名,需要管理员权限
parameters:
- description: 托管域名 ID
in: path
name: id
required: true
type: integer
- description: 托管域名参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/tls.ManagedDomainInput'
produces:
- application/json
responses:
"200":
description: 更新后的托管域名
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/model.ManagedDomain'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 记录不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 内部错误
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 更新托管域名
tags:
- openflare-tls
/api/v1/d/managed-domains/match:
get:
description: 按域名查询可用的证书匹配候选,需要管理员权限
parameters:
- description: 域名
in: query
name: domain
required: true
type: string
produces:
- application/json
responses:
"200":
description: 证书匹配结果
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/tls.ManagedDomainMatchResult'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限或不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 内部错误
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 匹配托管域名证书
tags:
- openflare-tls
/api/v1/d/nodes:
get:
description: 返回所有节点及最新配置下发记录,需要管理员权限
@@ -10669,6 +10542,136 @@ paths:
summary: 替换站点 WAF 规则组
tags:
- openflare-waf
/api/v1/d/zones:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
items:
$ref: '#/definitions/model.Zone'
type: array
type: object
security:
- SessionCookie: []
summary: 获取 Zone 列表
tags:
- openflare-zone
post:
consumes:
- application/json
parameters:
- description: Zone 参数
in: body
name: body
required: true
schema:
$ref: '#/definitions/zone.Input'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/model.Zone'
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.Any'
"409":
description: Conflict
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 创建 Zone
tags:
- openflare-zone
/api/v1/d/zones/{id}/domains:
post:
consumes:
- application/json
parameters:
- description: Zone ID
in: path
name: id
required: true
type: integer
- description: 域名参数
in: body
name: body
required: true
schema:
$ref: '#/definitions/zone.DomainInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/model.ZoneDomain'
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.Any'
"404":
description: Not Found
schema:
$ref: '#/definitions/response.Any'
"409":
description: Conflict
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 创建 Zone 域名
tags:
- openflare-zone
/api/v1/d/zones/{id}/overview:
get:
parameters:
- description: Zone ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/zone.Overview'
type: object
"404":
description: Not Found
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 获取 Zone 概览
tags:
- openflare-zone
/api/v1/oauth/{source}/authorize:
get:
description: 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。
@@ -42,7 +42,8 @@ func setupSecurityTest(t *testing.T) (*gin.Engine, adminSeed, func()) {
&model.OpenFlareWAFRuleGroupBinding{},
&model.OpenFlareWAFIPGroup{},
&model.TLSCertificate{},
&model.ManagedDomain{},
&model.Zone{},
&model.ZoneDomain{},
&model.DNSAccount{},
&model.AcmeAccount{},
&model.SystemConfig{},
@@ -289,12 +290,23 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
assert.Equal(t, "upload", data["provider"])
})
t.Run("create managed domain", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, apiPath("/managed-domains/"), map[string]any{
"domain": "security.example.com",
"cert_id": certID,
"enabled": true,
"remark": "primary security domain",
t.Run("create Zone domain", func(t *testing.T) {
zoneRec := performJSONRequest(t, engine, http.MethodPost, apiPath("/zones/"), map[string]any{
"domain": "example.com",
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, zoneRec.Code)
zoneData := unmarshalAPIMap(t, requireAPIOK(t, zoneRec).Data)
zoneID := uint(zoneData["id"].(float64))
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)
rec = performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
"domain": "security.example.com", "cert_id": certID, "remark": "primary security domain",
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
@@ -304,7 +316,6 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
assert.NotZero(t, domainID)
assert.Equal(t, "security.example.com", data["domain"])
assert.Equal(t, float64(certID), data["cert_id"])
assert.Equal(t, true, data["enabled"])
})
t.Run("create DNS account", func(t *testing.T) {
+1 -6
View File
@@ -339,7 +339,6 @@ func RenewCertificateHandler(c *gin.Context) {
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains [get]
func GetManagedDomains(c *gin.Context) {
domains, err := ListManagedDomains(c.Request.Context())
if handleLogicError(c, err) {
@@ -361,7 +360,6 @@ func GetManagedDomains(c *gin.Context) {
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains [post]
func CreateManagedDomainHandler(c *gin.Context) {
var input ManagedDomainInput
if !apiutil.BindJSON(c, &input) {
@@ -389,7 +387,6 @@ func CreateManagedDomainHandler(c *gin.Context) {
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains/{id}/update [post]
func UpdateManagedDomainHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
@@ -419,7 +416,6 @@ func UpdateManagedDomainHandler(c *gin.Context) {
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains/{id}/delete [post]
func DeleteManagedDomainHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
@@ -443,7 +439,6 @@ func DeleteManagedDomainHandler(c *gin.Context) {
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/managed-domains/match [get]
func MatchManagedDomainCertificateHandler(c *gin.Context) {
domain := strings.TrimSpace(c.Query("domain"))
result, err := MatchManagedDomainCertificate(c.Request.Context(), domain)
@@ -575,4 +570,4 @@ func GetDefaultAcmeAccountHandler(c *gin.Context) {
return
}
c.JSON(http.StatusOK, response.OK(account))
}
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
const (
errZoneDomainRequired = "域名不能为空"
errZoneRootInvalid = "zone 必须是有效的注册根域"
errDomainInvalid = "域名格式不合法"
errDomainWildcardUnsupported = "不支持通配符域名"
errDomainOutsideZone = "域名不属于该 Zone"
errZoneNotFound = "Zone 不存在"
errDomainNotFound = "域名不存在"
errDomainExists = "域名已存在"
errCertificateNotFound = "所选证书不存在"
)
@@ -0,0 +1,147 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
// ImportReport describes the idempotent legacy migration result.
type ImportReport struct {
Zones int `json:"zones"`
Domains int `json:"domains"`
Conflicts []string `json:"conflicts,omitempty"`
}
// LogAndReturn decorates the import failure with its conflict count.
func (r ImportReport) LogAndReturn(err error) error {
if err != nil {
return fmt.Errorf("迁移 Zone 失败(%d 个冲突): %w", len(r.Conflicts), err)
}
return nil
}
type legacyDomain struct {
Domain string
CertID *uint
Remark string
}
// ImportLegacy imports legacy proxy-route names first, and managed domains only when routes contain no domains.
// ImportLegacy imports legacy records atomically after collecting validation conflicts.
//
//nolint:cyclop // the transactional importer intentionally validates every legacy source in one pass.
func ImportLegacy(ctx context.Context) (report ImportReport, resultErr error) {
conn := db.DB(ctx)
if conn == nil {
return report, fmt.Errorf("database is not initialized")
}
resultErr = conn.Transaction(func(tx *gorm.DB) error {
var routes []model.ProxyRoute
if err := tx.Find(&routes).Error; err != nil {
return err
}
items := make([]legacyDomain, 0)
hasRouteDomains := false
for _, route := range routes {
domains, err := routeidentity.DecodeDomains(route.Domains, route.Domain)
if err != nil {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("route %d: %v", route.ID, err))
continue
}
if len(domains) > 0 {
hasRouteDomains = true
}
certIDs := decodeLegacyCertIDs(route.DomainCertIDs, len(domains))
for i, domain := range domains {
var certID *uint
if i < len(certIDs) && certIDs[i] > 0 {
v := certIDs[i]
certID = &v
}
items = append(items, legacyDomain{Domain: domain, CertID: certID, Remark: route.Remark})
}
}
if !hasRouteDomains {
var legacy []model.ManagedDomain
if err := tx.Find(&legacy).Error; err != nil {
return err
}
for _, item := range legacy {
items = append(items, legacyDomain{Domain: item.Domain, CertID: item.CertID, Remark: item.Remark})
}
}
for _, item := range items {
domain, err := normalizeDomain(item.Domain)
if err != nil {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", item.Domain, err))
continue
}
root, err := zoneRoot(domain)
if err != nil {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %v", domain, err))
continue
}
var existing model.ZoneDomain
err = tx.Where("domain = ?", domain).First(&existing).Error
if err == nil {
var z model.Zone
if tx.First(&z, existing.ZoneID).Error != nil || z.Domain != root {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: global domain conflict", domain))
}
continue
}
if err != nil && !isNotFound(err) {
return err
}
var zone model.Zone
err = tx.Where("domain = ?", root).First(&zone).Error
if isNotFound(err) {
zone = model.Zone{Domain: root}
if err = tx.Create(&zone).Error; err != nil {
return err
}
report.Zones++
} else if err != nil {
return err
}
if item.CertID != nil {
var cert model.TLSCertificate
if err = tx.First(&cert, *item.CertID).Error; err != nil {
report.Conflicts = append(report.Conflicts, fmt.Sprintf("%s: %s", domain, errCertificateNotFound))
continue
}
}
if err = tx.Create(&model.ZoneDomain{ZoneID: zone.ID, Domain: domain, CertID: item.CertID, Remark: item.Remark}).Error; err != nil {
return err
}
report.Domains++
}
if len(report.Conflicts) > 0 {
return fmt.Errorf("legacy data has conflicts")
}
return nil
})
return report, resultErr
}
func decodeLegacyCertIDs(raw string, count int) []uint {
var values []uint
if strings.TrimSpace(raw) == "" {
return make([]uint, count)
}
if json.Unmarshal([]byte(raw), &values) != nil {
return make([]uint, count)
}
return values
}
func isNotFound(err error) bool { return err == gorm.ErrRecordNotFound }
+186
View File
@@ -0,0 +1,186 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package zone manages registered roots and their explicit hostnames.
package zone
import (
"context"
"errors"
"strings"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"golang.org/x/net/publicsuffix"
"gorm.io/gorm"
)
// Input is the mutable Zone payload.
type Input struct {
Domain string `json:"domain"`
Remark string `json:"remark"`
}
// DomainInput is the mutable Zone-domain payload.
type DomainInput struct {
Domain string `json:"domain"`
CertID *uint `json:"cert_id"`
Remark string `json:"remark"`
}
// Overview joins a Zone with its explicit domains.
type Overview struct {
Zone model.Zone `json:"zone"`
Domains []model.ZoneDomain `json:"domains"`
}
func zoneRoot(domain string) (string, error) {
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain)))
}
func normalizeDomain(raw string) (string, error) {
domain := strings.ToLower(strings.TrimSpace(raw))
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, "@") {
return "", errors.New(errDomainInvalid)
}
if _, err := zoneRoot(domain); err != nil {
return "", errors.New(errDomainInvalid)
}
return domain, nil
}
// Create persists a validated registered root.
func Create(ctx context.Context, input Input) (*model.Zone, error) {
domain, err := normalizeDomain(input.Domain)
if err != nil {
return nil, err
}
root, err := zoneRoot(domain)
if err != nil || root != domain {
return nil, errors.New(errZoneRootInvalid)
}
zone := &model.Zone{Domain: domain, Remark: strings.TrimSpace(input.Remark)}
if err := db.DB(ctx).Create(zone).Error; err != nil {
if isUnique(err) {
return nil, errors.New(errDomainExists)
}
return nil, err
}
return zone, nil
}
// Update replaces a Zone's mutable fields.
func Update(ctx context.Context, id uint, input Input) (*model.Zone, error) {
var zone model.Zone
if err := db.DB(ctx).First(&zone, id).Error; err != nil {
return nil, err
}
domain, err := normalizeDomain(input.Domain)
if err != nil {
return nil, err
}
root, err := zoneRoot(domain)
if err != nil || root != domain {
return nil, errors.New(errZoneRootInvalid)
}
zone.Domain, zone.Remark = domain, strings.TrimSpace(input.Remark)
if err := db.DB(ctx).Save(&zone).Error; err != nil {
if isUnique(err) {
return nil, errors.New(errDomainExists)
}
return nil, err
}
return &zone, nil
}
// List returns all Zones in stable domain order.
func List(ctx context.Context) ([]model.Zone, error) {
var zones []model.Zone
err := db.DB(ctx).Order("domain asc").Find(&zones).Error
return zones, err
}
// GetOverview returns a Zone and its domains.
func GetOverview(ctx context.Context, id uint) (*Overview, error) {
var zone model.Zone
if err := db.DB(ctx).First(&zone, id).Error; err != nil {
return nil, err
}
var domains []model.ZoneDomain
if err := db.DB(ctx).Where("zone_id = ?", id).Order("domain asc").Find(&domains).Error; err != nil {
return nil, err
}
return &Overview{Zone: zone, Domains: domains}, nil
}
// CreateDomain adds a validated exact hostname to a Zone.
func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error) {
var zone model.Zone
if err := db.DB(ctx).First(&zone, zoneID).Error; err != nil {
return nil, err
}
domain, err := normalizeDomain(input.Domain)
if err != nil {
return nil, err
}
root, err := zoneRoot(domain)
if err != nil || root != zone.Domain {
return nil, errors.New(errDomainOutsideZone)
}
if input.CertID != nil {
if _, err := model.GetTLSCertificateByID(ctx, *input.CertID); err != nil {
return nil, errors.New(errCertificateNotFound)
}
}
item := &model.ZoneDomain{ZoneID: zoneID, Domain: domain, CertID: input.CertID, Remark: strings.TrimSpace(input.Remark)}
if err := db.DB(ctx).Create(item).Error; err != nil {
if isUnique(err) {
return nil, errors.New(errDomainExists)
}
return nil, err
}
return item, nil
}
// UpdateDomain replaces a Zone-domain's mutable fields.
func UpdateDomain(ctx context.Context, zoneID, id uint, input DomainInput) (*model.ZoneDomain, error) {
var item model.ZoneDomain
if err := db.DB(ctx).Where("id = ? AND zone_id = ?", id, zoneID).First(&item).Error; err != nil {
return nil, err
}
domain, err := normalizeDomain(input.Domain)
if err != nil {
return nil, err
}
var zone model.Zone
if err = db.DB(ctx).First(&zone, zoneID).Error; err != nil {
return nil, err
}
root, err := zoneRoot(domain)
if err != nil || root != zone.Domain {
return nil, errors.New(errDomainOutsideZone)
}
if input.CertID != nil {
if _, err = model.GetTLSCertificateByID(ctx, *input.CertID); err != nil {
return nil, errors.New(errCertificateNotFound)
}
}
item.Domain, item.CertID, item.Remark = domain, input.CertID, strings.TrimSpace(input.Remark)
if err = db.DB(ctx).Save(&item).Error; err != nil {
if isUnique(err) {
return nil, errors.New(errDomainExists)
}
return nil, err
}
return &item, nil
}
func isUnique(err error) bool {
return errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(strings.ToLower(err.Error()), "unique constraint")
}
@@ -0,0 +1,39 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupZoneDB(t *testing.T) context.Context {
t.Helper()
conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
require.NoError(t, err)
require.NoError(t, conn.AutoMigrate(&model.Zone{}, &model.ZoneDomain{}, &model.TLSCertificate{}))
db.SetDB(conn)
t.Cleanup(func() { db.SetDB(nil) })
return context.Background()
}
func TestCreateZoneDomainRejectsWildcard(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)
}
func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
root, err := zoneRoot("api.example.co.uk")
require.NoError(t, err)
require.Equal(t, "example.co.uk", root)
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package zone
import (
"errors"
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func abort(c *gin.Context, err error, missing string) bool {
if err == nil {
return false
}
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
response.AbortNotFound(c, missing)
case err.Error() == errDomainExists:
response.AbortConflict(c, err.Error())
default:
response.AbortBadRequest(c, err.Error())
}
return true
}
// ListHandler lists registered Zones.
// @Summary 获取 Zone 列表
// @Tags openflare-zone
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.Zone}
// @Router /api/v1/d/zones [get]
func ListHandler(c *gin.Context) {
items, err := List(c.Request.Context())
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(items))
}
// CreateHandler creates a registered root domain.
// @Summary 创建 Zone
// @Tags openflare-zone
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param body body zone.Input true "Zone 参数"
// @Success 200 {object} response.Any{data=model.Zone}
// @Failure 400 {object} response.Any
// @Failure 409 {object} response.Any
// @Router /api/v1/d/zones [post]
func CreateHandler(c *gin.Context) {
var input Input
if !apiutil.BindJSON(c, &input) {
return
}
item, err := Create(c.Request.Context(), input)
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// GetOverviewHandler returns a Zone and its explicit domains.
// @Summary 获取 Zone 概览
// @Tags openflare-zone
// @Produce json
// @Security SessionCookie
// @Param id path int true "Zone ID"
// @Success 200 {object} response.Any{data=zone.Overview}
// @Failure 404 {object} response.Any
// @Router /api/v1/d/zones/{id}/overview [get]
func GetOverviewHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
item, err := GetOverview(c.Request.Context(), id)
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// CreateDomainHandler creates an explicit FQDN under a Zone.
// @Summary 创建 Zone 域名
// @Tags openflare-zone
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "Zone ID"
// @Param body body zone.DomainInput true "域名参数"
// @Success 200 {object} response.Any{data=model.ZoneDomain}
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Failure 409 {object} response.Any
// @Router /api/v1/d/zones/{id}/domains [post]
func CreateDomainHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input DomainInput
if !apiutil.BindJSON(c, &input) {
return
}
item, err := CreateDomain(c.Request.Context(), id, input)
if abort(c, err, errZoneNotFound) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"context"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/zone"
"github.com/Rain-kl/Wavelet/internal/db/migrator"
"github.com/spf13/cobra"
)
var migrateZonesCmd = &cobra.Command{
Use: "migrate-zones", Short: "导入旧域名数据到 Zone",
PreRun: func(_ *cobra.Command, _ []string) { migrator.Migrate() },
RunE: func(_ *cobra.Command, _ []string) error {
report, err := zone.ImportLegacy(context.Background())
return report.LogAndReturn(err)
},
}
+1 -1
View File
@@ -72,7 +72,7 @@ func init() {
schedulerCmd.PreRun = migratePreRun
// 2. 集中将这些命令注册为真正的子命令,以解决 Cobra 的 unknown command 校验限制
rootCmd.AddCommand(allCmd, apiCmd, workerCmd, schedulerCmd)
rootCmd.AddCommand(allCmd, apiCmd, workerCmd, schedulerCmd, migrateZonesCmd)
}
// Execute 执行根命令
@@ -0,0 +1,28 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_zones (
id BIGSERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain);
CREATE TABLE IF NOT EXISTS of_zone_domains (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT NOT NULL,
proxy_route_id BIGINT,
domain VARCHAR(255) NOT NULL,
cert_id BIGINT,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain);
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id);
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id);
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id);
-- +goose Down
DROP TABLE IF EXISTS of_zone_domains;
DROP TABLE IF EXISTS of_zones;
@@ -0,0 +1,28 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
remark TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain);
CREATE TABLE IF NOT EXISTS of_zone_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zone_id INTEGER NOT NULL,
proxy_route_id INTEGER,
domain TEXT NOT NULL,
cert_id INTEGER,
remark TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain);
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id);
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id);
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id);
-- +goose Down
DROP TABLE IF EXISTS of_zone_domains;
DROP TABLE IF EXISTS of_zones;
+23
View File
@@ -76,6 +76,29 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
if templateCount != 2 {
t.Errorf("Migrate() templates count = %d, want %d", templateCount, 2)
}
if !sqliteDB.Migrator().HasTable("of_zones") {
t.Error("Migrate() did not create of_zones")
}
if !sqliteDB.Migrator().HasTable("of_zone_domains") {
t.Error("Migrate() did not create of_zone_domains")
}
zone := model.Zone{Domain: "example.com"}
if err := sqliteDB.Create(&zone).Error; err != nil {
t.Fatalf("Migrate() create Zone error = %v", err)
}
if err := sqliteDB.Create(&model.Zone{Domain: zone.Domain}).Error; err == nil {
t.Error("Migrate() allowed duplicate of_zones.domain")
}
domain := model.ZoneDomain{ZoneID: zone.ID, Domain: "api.example.com"}
if err := sqliteDB.Create(&domain).Error; err != nil {
t.Fatalf("Migrate() create ZoneDomain error = %v", err)
}
if err := sqliteDB.Create(&model.ZoneDomain{ZoneID: zone.ID, Domain: domain.Domain}).Error; err == nil {
t.Error("Migrate() allowed duplicate of_zone_domains.domain")
}
}
func TestMigrateClearsStaleSystemConfigCache(t *testing.T) {
+33 -32
View File
@@ -12,38 +12,39 @@ import (
// ProxyRoute OpenFlare 代理规则实体。
type ProxyRoute struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
SiteName string `json:"site_name" gorm:"size:255;not null;default:''"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"`
OriginID *uint `json:"origin_id" gorm:"index"`
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
OriginHost string `json:"origin_host" gorm:"size:255"`
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
CertID *uint `json:"cert_id"`
CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"`
DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"`
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"`
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"`
Remark string `json:"remark" gorm:"size:255"`
UpstreamType string `json:"upstream_type" gorm:"size:32;not null;default:'direct'"`
TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"`
TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"`
TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"`
PagesProjectID *uint `json:"pages_project_id" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
SiteName string `json:"site_name" gorm:"size:255;not null;default:''"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"`
OriginID *uint `json:"origin_id" gorm:"index"`
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
OriginHost string `json:"origin_host" gorm:"size:255"`
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
CertID *uint `json:"cert_id"`
CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"`
DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"`
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"`
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"`
Remark string `json:"remark" gorm:"size:255"`
UpstreamType string `json:"upstream_type" gorm:"size:32;not null;default:'direct'"`
TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"`
TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"`
TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"`
PagesProjectID *uint `json:"pages_project_id" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
ZoneDomains []ZoneDomain `json:"zone_domains,omitempty" gorm:"foreignKey:ProxyRouteID"`
}
// TableName 表名。
+115
View File
@@ -0,0 +1,115 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"errors"
"fmt"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
tableOfZones = "of_zones"
tableOfZoneDomains = "of_zone_domains"
)
var errZoneDomainBoundToAnotherRoute = errors.New("zone domain is already bound to another proxy route")
// Zone OpenFlare 注册根域实体。
type Zone struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
Domain string `json:"domain" gorm:"uniqueIndex:idx_of_zones_domain;size:255;not null"`
Remark string `json:"remark" gorm:"size:255;not null;default:''"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名。
func (Zone) TableName() string {
return tableOfZones
}
// 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"`
ProxyRouteID *uint `json:"proxy_route_id" gorm:"index:idx_of_zone_domains_proxy_route_id"`
Domain string `json:"domain" gorm:"uniqueIndex:idx_of_zone_domains_domain;size:255;not null"`
CertID *uint `json:"cert_id" gorm:"index:idx_of_zone_domains_cert_id"`
Remark string `json:"remark" gorm:"size:255;not null;default:''"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名。
func (ZoneDomain) TableName() string {
return tableOfZoneDomains
}
// ListZoneDomainsByRouteID returns the domains bound to a proxy route.
func ListZoneDomainsByRouteID(ctx context.Context, routeID uint) ([]ZoneDomain, error) {
var domains []ZoneDomain
if err := db.DB(ctx).Where("proxy_route_id = ?", routeID).Order("id asc").Find(&domains).Error; err != nil {
return nil, err
}
return domains, nil
}
// ReplaceZoneDomainRouteBindings replaces every ZoneDomain binding for a proxy route.
func ReplaceZoneDomainRouteBindings(ctx context.Context, routeID uint, domainIDs []uint) error {
conn := db.DB(ctx)
if conn == nil {
return errors.New("database is not initialized")
}
return conn.Transaction(func(tx *gorm.DB) error {
var requested []ZoneDomain
if len(domainIDs) > 0 {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id IN ?", domainIDs).
Find(&requested).Error; err != nil {
return err
}
if len(requested) != len(uniqueZoneDomainIDs(domainIDs)) {
return fmt.Errorf("one or more zone domains do not exist")
}
for _, domain := range requested {
if domain.ProxyRouteID != nil && *domain.ProxyRouteID != routeID {
return errZoneDomainBoundToAnotherRoute
}
}
}
current := tx.Model(&ZoneDomain{}).Where("proxy_route_id = ?", routeID)
if len(domainIDs) > 0 {
current = current.Where("id NOT IN ?", domainIDs)
}
if err := current.Update("proxy_route_id", nil).Error; err != nil {
return err
}
if len(domainIDs) == 0 {
return nil
}
return tx.Model(&ZoneDomain{}).Where("id IN ?", domainIDs).Update("proxy_route_id", routeID).Error
})
}
func uniqueZoneDomainIDs(domainIDs []uint) []uint {
seen := make(map[uint]struct{}, len(domainIDs))
ids := make([]uint, 0, len(domainIDs))
for _, id := range domainIDs {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupZoneTestDB(t *testing.T) *gorm.DB {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&Zone{}, &ZoneDomain{}))
db.SetDB(sqliteDB)
t.Cleanup(func() { db.SetDB(nil) })
return sqliteDB
}
func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) {
conn := setupZoneTestDB(t)
ctx := context.Background()
zone := Zone{Domain: "example.com"}
require.NoError(t, conn.Create(&zone).Error)
foreignRouteID := uint(11)
domain := ZoneDomain{
ZoneID: zone.ID,
ProxyRouteID: &foreignRouteID,
Domain: "api.example.com",
}
require.NoError(t, conn.Create(&domain).Error)
err := ReplaceZoneDomainRouteBindings(ctx, 12, []uint{domain.ID})
require.Error(t, err)
var got ZoneDomain
require.NoError(t, conn.First(&got, domain.ID).Error)
require.Equal(t, &foreignRouteID, got.ProxyRouteID)
}
func TestReplaceZoneDomainRouteBindingsReplacesCurrentRouteBindings(t *testing.T) {
conn := setupZoneTestDB(t)
ctx := context.Background()
zone := Zone{Domain: "example.com"}
require.NoError(t, conn.Create(&zone).Error)
routeID := uint(21)
boundDomain := ZoneDomain{ZoneID: zone.ID, ProxyRouteID: &routeID, Domain: "old.example.com"}
requestedDomain := ZoneDomain{ZoneID: zone.ID, Domain: "new.example.com"}
require.NoError(t, conn.Create(&boundDomain).Error)
require.NoError(t, conn.Create(&requestedDomain).Error)
require.NoError(t, ReplaceZoneDomainRouteBindings(ctx, routeID, []uint{requestedDomain.ID}))
var domains []ZoneDomain
require.NoError(t, conn.Order("id asc").Find(&domains).Error)
require.Len(t, domains, 2)
require.Nil(t, domains[0].ProxyRouteID)
require.Equal(t, &routeID, domains[1].ProxyRouteID)
}
func TestListZoneDomainsByRouteID(t *testing.T) {
conn := setupZoneTestDB(t)
ctx := context.Background()
zone := Zone{Domain: "example.com"}
require.NoError(t, conn.Create(&zone).Error)
routeID := uint(31)
boundDomain := ZoneDomain{ZoneID: zone.ID, ProxyRouteID: &routeID, Domain: "api.example.com"}
unboundDomain := ZoneDomain{ZoneID: zone.ID, Domain: "www.example.com"}
require.NoError(t, conn.Create(&boundDomain).Error)
require.NoError(t, conn.Create(&unboundDomain).Error)
domains, err := ListZoneDomainsByRouteID(ctx, routeID)
require.NoError(t, err)
require.Len(t, domains, 1)
require.Equal(t, boundDomain.ID, domains[0].ID)
}
@@ -10,16 +10,6 @@ import (
)
func registerTLSRoutes(apiGroup *gin.RouterGroup) {
managedDomainRoute := apiGroup.Group("/managed-domains")
managedDomainRoute.Use(apiutil.AdminMiddlewares()...)
{
apiutil.RegisterCollection(managedDomainRoute, "GET", tls.GetManagedDomains)
managedDomainRoute.GET("/match", tls.MatchManagedDomainCertificateHandler)
apiutil.RegisterCollection(managedDomainRoute, "POST", tls.CreateManagedDomainHandler)
managedDomainRoute.POST("/:id/update", tls.UpdateManagedDomainHandler)
managedDomainRoute.POST("/:id/delete", tls.DeleteManagedDomainHandler)
}
tlsCertificateRoute := apiGroup.Group("/tls-certificates")
tlsCertificateRoute.Use(apiutil.AdminMiddlewares()...)
{
@@ -0,0 +1,19 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/zone"
"github.com/gin-gonic/gin"
)
func registerZoneRoutes(apiGroup *gin.RouterGroup) {
zoneGroup := apiGroup.Group("/zones")
zoneGroup.Use(apiutil.AdminMiddlewares()...)
apiutil.RegisterCollection(zoneGroup, "GET", zone.ListHandler)
apiutil.RegisterCollection(zoneGroup, "POST", zone.CreateHandler)
zoneGroup.GET("/:id/overview", zone.GetOverviewHandler)
zoneGroup.POST("/:id/domains", zone.CreateDomainHandler)
}
+1
View File
@@ -18,6 +18,7 @@ func RegisterV1Routes(apiV1Router *gin.RouterGroup) {
registerNodeRoutes(group)
registerWAFRoutes(group)
registerTLSRoutes(group)
registerZoneRoutes(group)
registerConfigVersionRoutes(group)
registerPagesRoutes(group)
registerDashboardRoutes(group)