From 50678756d4e726ad314c2be728594855cdbee130 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 12 Jul 2026 14:23:25 +0800 Subject: [PATCH 1/2] feat(zone): add normalized zone domain schema --- docs/changelog/index.md | 4 + ...202607120001_create_zone_domain_tables.sql | 28 +++++ ...202607120001_create_zone_domain_tables.sql | 28 +++++ internal/db/migrator/migrator_test.go | 23 ++++ internal/model/openflare_proxy_route.go | 65 +++++----- internal/model/openflare_zone.go | 115 ++++++++++++++++++ internal/model/openflare_zone_test.go | 88 ++++++++++++++ 7 files changed, 319 insertions(+), 32 deletions(-) create mode 100644 internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql create mode 100644 internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql create mode 100644 internal/model/openflare_zone.go create mode 100644 internal/model/openflare_zone_test.go diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 6e568fbf..7f269017 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -21,6 +21,10 @@ sidebar: false ## [unreleased] +### 新增 + +- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。 + ### 修复 - Docker ClickHouse 性能配置改为单文件挂载,避免覆盖镜像内置的 Docker 网络监听配置,导致宿主机无法通过 8123/9000 访问服务。 diff --git a/internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql b/internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql new file mode 100644 index 00000000..00b8170e --- /dev/null +++ b/internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql @@ -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; diff --git a/internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql b/internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql new file mode 100644 index 00000000..637fe80e --- /dev/null +++ b/internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql @@ -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; diff --git a/internal/db/migrator/migrator_test.go b/internal/db/migrator/migrator_test.go index 7badfd4b..27238aa4 100644 --- a/internal/db/migrator/migrator_test.go +++ b/internal/db/migrator/migrator_test.go @@ -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) { diff --git a/internal/model/openflare_proxy_route.go b/internal/model/openflare_proxy_route.go index 4a584bf6..7139081b 100644 --- a/internal/model/openflare_proxy_route.go +++ b/internal/model/openflare_proxy_route.go @@ -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 表名。 diff --git a/internal/model/openflare_zone.go b/internal/model/openflare_zone.go new file mode 100644 index 00000000..a9e42f45 --- /dev/null +++ b/internal/model/openflare_zone.go @@ -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 +} diff --git a/internal/model/openflare_zone_test.go b/internal/model/openflare_zone_test.go new file mode 100644 index 00000000..c811a77f --- /dev/null +++ b/internal/model/openflare_zone_test.go @@ -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) +} From 53c868e99bb6deb2845837ce9d7a1ea4997ede2f Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 12 Jul 2026 14:35:44 +0800 Subject: [PATCH 2/2] feat(zone): add zone management api and legacy importer --- .superpowers/sdd/task-2-report.md | 16 + docs/changelog/index.md | 1 + docs/docs.go | 701 +++++++++--------- docs/swagger.json | 701 +++++++++--------- docs/swagger.yaml | 427 +++++------ .../openflare/integration/security_test.go | 27 +- internal/apps/openflare/tls/routers.go | 7 +- internal/apps/openflare/zone/errs.go | 16 + internal/apps/openflare/zone/legacy_import.go | 147 ++++ internal/apps/openflare/zone/logics.go | 186 +++++ internal/apps/openflare/zone/logics_test.go | 39 + internal/apps/openflare/zone/routers.go | 117 +++ internal/cmd/migrate_zones.go | 21 + internal/cmd/root.go | 2 +- internal/router/v1/openflare/register_tls.go | 10 - internal/router/v1/openflare/register_zone.go | 19 + internal/router/v1/openflare/v1.go | 1 + 17 files changed, 1505 insertions(+), 933 deletions(-) create mode 100644 .superpowers/sdd/task-2-report.md create mode 100644 internal/apps/openflare/zone/errs.go create mode 100644 internal/apps/openflare/zone/legacy_import.go create mode 100644 internal/apps/openflare/zone/logics.go create mode 100644 internal/apps/openflare/zone/logics_test.go create mode 100644 internal/apps/openflare/zone/routers.go create mode 100644 internal/cmd/migrate_zones.go create mode 100644 internal/router/v1/openflare/register_zone.go diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 00000000..f6b121ac --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -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. diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 7f269017..6e15e726 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -24,6 +24,7 @@ sidebar: false ### 新增 - 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。 +- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。 ### 修复 diff --git a/docs/docs.go b/docs/docs.go index 8bca2bb7..9c4cde39 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index 677daec8..2f43e071 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4672242c..e2eed193 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 参数用于区分登录和账号绑定场景。认证源必须已启用。 diff --git a/internal/apps/openflare/integration/security_test.go b/internal/apps/openflare/integration/security_test.go index 41daf8de..b29a6aab 100644 --- a/internal/apps/openflare/integration/security_test.go +++ b/internal/apps/openflare/integration/security_test.go @@ -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) { diff --git a/internal/apps/openflare/tls/routers.go b/internal/apps/openflare/tls/routers.go index 1095df59..0136cad4 100644 --- a/internal/apps/openflare/tls/routers.go +++ b/internal/apps/openflare/tls/routers.go @@ -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)) -} \ No newline at end of file +} diff --git a/internal/apps/openflare/zone/errs.go b/internal/apps/openflare/zone/errs.go new file mode 100644 index 00000000..5a32c214 --- /dev/null +++ b/internal/apps/openflare/zone/errs.go @@ -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 = "所选证书不存在" +) diff --git a/internal/apps/openflare/zone/legacy_import.go b/internal/apps/openflare/zone/legacy_import.go new file mode 100644 index 00000000..3dcff6c9 --- /dev/null +++ b/internal/apps/openflare/zone/legacy_import.go @@ -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 } diff --git a/internal/apps/openflare/zone/logics.go b/internal/apps/openflare/zone/logics.go new file mode 100644 index 00000000..e3aa31a7 --- /dev/null +++ b/internal/apps/openflare/zone/logics.go @@ -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") +} diff --git a/internal/apps/openflare/zone/logics_test.go b/internal/apps/openflare/zone/logics_test.go new file mode 100644 index 00000000..cb4334fb --- /dev/null +++ b/internal/apps/openflare/zone/logics_test.go @@ -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) +} diff --git a/internal/apps/openflare/zone/routers.go b/internal/apps/openflare/zone/routers.go new file mode 100644 index 00000000..455c7361 --- /dev/null +++ b/internal/apps/openflare/zone/routers.go @@ -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)) +} diff --git a/internal/cmd/migrate_zones.go b/internal/cmd/migrate_zones.go new file mode 100644 index 00000000..92935451 --- /dev/null +++ b/internal/cmd/migrate_zones.go @@ -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) + }, +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c287cf84..daecd885 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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 执行根命令 diff --git a/internal/router/v1/openflare/register_tls.go b/internal/router/v1/openflare/register_tls.go index 8d87809a..59417297 100644 --- a/internal/router/v1/openflare/register_tls.go +++ b/internal/router/v1/openflare/register_tls.go @@ -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()...) { diff --git a/internal/router/v1/openflare/register_zone.go b/internal/router/v1/openflare/register_zone.go new file mode 100644 index 00000000..07580c1a --- /dev/null +++ b/internal/router/v1/openflare/register_zone.go @@ -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) +} diff --git a/internal/router/v1/openflare/v1.go b/internal/router/v1/openflare/v1.go index 148a82f3..128b6727 100644 --- a/internal/router/v1/openflare/v1.go +++ b/internal/router/v1/openflare/v1.go @@ -18,6 +18,7 @@ func RegisterV1Routes(apiV1Router *gin.RouterGroup) { registerNodeRoutes(group) registerWAFRoutes(group) registerTLSRoutes(group) + registerZoneRoutes(group) registerConfigVersionRoutes(group) registerPagesRoutes(group) registerDashboardRoutes(group)