feat(zone): add zone management api and legacy importer

This commit is contained in:
ryan
2026-07-12 14:35:44 +08:00
parent 50678756d4
commit 53c868e99b
17 changed files with 1505 additions and 933 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.
+1
View File
@@ -24,6 +24,7 @@ sidebar: false
### 新增
- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。
- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。
### 修复
+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 执行根命令
@@ -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)