From 1434cfd72b63768dda10b769d6149caff0c8e4c6 Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Mon, 28 Sep 2026 18:47:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(cloudflare):=20=E8=87=AA=E5=8A=A8=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E9=9A=A7=E9=81=93=20DNS=20=E8=AE=B0=E5=BD=95=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E5=B8=83=20v1.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 19 ++++++++++++ VERSION | 2 +- docs/README.md | 2 +- docs/system-upgrade.md | 8 ++--- internal/cloudflared/cloudflare_api.go | 41 ++++++++++++++++++++++++++ internal/cloudflared/model.go | 1 + internal/cloudflared/service.go | 39 +++++++++++++++++++++++- web/package-lock.json | 4 +-- web/package.json | 2 +- web/src/api/types.ts | 1 + web/src/views/CloudflareView.vue | 16 +++++----- web/src/views/ExampleView.vue | 16 +++++----- 12 files changed, 126 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3acd73a..b1b9caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [v1.7.0] - 2026-09-28 + +### 新增 + +- **自动创建 Cloudflare DNS 记录**:创建隧道时,Fonu 会为每个暴露域名查找所属 Cloudflare Zone(从 hostname 逐级向上)并幂等创建 / 更新 CNAME: + - 目标为 `.cfargotunnel.com` + - 自动开启代理(橙云) + - 同名 CNAME 已存在时更新而不是重复创建 + - 编辑隧道时同步 DNS;创建失败只在页面提示警告,不影响隧道本身 +- API Token 权限说明更新为:`Account: Cloudflare Tunnel: Edit`、`Zone: Zone: Read`、`Zone: DNS: Edit` + +### 变更 + +- 版本号提升到 1.7.0;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + ## [v1.6.9] - 2026-09-28 ### 修复 diff --git a/VERSION b/VERSION index 15d45d4..bd8bf88 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.9 +1.7.0 diff --git a/docs/README.md b/docs/README.md index c1604ba..828fdd8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.6.9) +> 最近更新:2026-09-28(v1.7.0) ## 方案与设计(本目录) diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index d3f6b9c..d885ca8 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu ## 10. 在线升级验证 -示例页面用于验证升级结果。v1.6.9 的验证流程: +示例页面用于验证升级结果。v1.7.0 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.9`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.7.0`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.6.9`; - - 页面显示验证标识 `v1.6.9-example-page`; + - 页面显示的版本为 `1.7.0`; + - 页面显示验证标识 `v1.7.0-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/cloudflared/cloudflare_api.go b/internal/cloudflared/cloudflare_api.go index 272724b..4468043 100644 --- a/internal/cloudflared/cloudflare_api.go +++ b/internal/cloudflared/cloudflare_api.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -147,3 +148,43 @@ func (a *CloudflareAPI) PutIngress(ctx context.Context, tunnelID string, ingress func (a *CloudflareAPI) DeleteTunnel(ctx context.Context, tunnelID string) error { return a.do(ctx, http.MethodDelete, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"?cascade=true", nil, nil) } + +// Zone 是 Cloudflare 的域名区域。 +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// FindZone 从 hostname 逐级向上查找所属 Zone。 +func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string, string, error) { + labels := strings.Split(strings.Trim(hostname, "."), ".") + for i := 0; i < len(labels)-1; i++ { + candidate := strings.Join(labels[i:], ".") + var zones []Zone + if err := a.do(ctx, http.MethodGet, "/zones?name="+url.QueryEscape(candidate), nil, &zones); err != nil { + return "", "", err + } + if len(zones) > 0 { + return zones[0].ID, zones[0].Name, nil + } + } + return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认 API Token 具备 Zone:Read 权限", hostname) +} + +// EnsureCNAME 幂等创建或更新指向隧道的 CNAME 记录,并开启代理(橙云)。 +func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) error { + var records []struct { + ID string `json:"id"` + } + path := "/zones/" + zoneID + "/dns_records?type=CNAME&name=" + url.QueryEscape(hostname) + if err := a.do(ctx, http.MethodGet, path, nil, &records); err != nil { + return err + } + payload := map[string]any{ + "type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1, + } + if len(records) > 0 { + return a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil) + } + return a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, nil) +} diff --git a/internal/cloudflared/model.go b/internal/cloudflared/model.go index 455d3ea..9287919 100644 --- a/internal/cloudflared/model.go +++ b/internal/cloudflared/model.go @@ -55,6 +55,7 @@ type Tunnel struct { AutoStart bool `json:"auto_start"` Hostnames []string `json:"hostnames"` OriginService string `json:"origin_service"` + DNSWarning string `json:"dns_warning,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/cloudflared/service.go b/internal/cloudflared/service.go index cd5ac99..1dd092f 100644 --- a/internal/cloudflared/service.go +++ b/internal/cloudflared/service.go @@ -121,6 +121,10 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) { return Tunnel{}, err } _ = api.PutIngress(ctx, decodedID, ingress) + if err := s.ensureTunnelDNS(ctx, api, decodedID, in.Hostnames); err != nil { + s.logger.Warn("自动创建 Cloudflare DNS 失败", "tunnel_id", decodedID, "error", err.Error()) + tunnel.DNSWarning = "隧道已创建,但 DNS 记录未自动创建:" + err.Error() + } return tunnel, nil } @@ -140,13 +144,23 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(BuildConfig(current.TunnelID, current.CredsPath, in.Network, ingress)), 0o640); err != nil { return Tunnel{}, err } + dnsWarning := "" if current.Mode == ModeAccountLocal && current.TunnelID != "" { if api, err := s.cloudflareAPI(ctx); err == nil { _ = api.PutIngress(ctx, current.TunnelID, ingress) + if err := s.ensureTunnelDNS(ctx, api, current.TunnelID, in.Hostnames); err != nil { + s.logger.Warn("同步 Cloudflare DNS 失败", "tunnel_id", current.TunnelID, "error", err.Error()) + dnsWarning = "DNS 记录同步失败:" + err.Error() + } } } - return s.store.Update(ctx, id, in, current.TunnelID, current.AccountTag, current.CredentialsEnc, current.CertPEMEnc, + updated, err := s.store.Update(ctx, id, in, current.TunnelID, current.AccountTag, current.CredentialsEnc, current.CertPEMEnc, current.ConfigPath, current.CredsPath, current.LogPath) + if err != nil { + return Tunnel{}, err + } + updated.DNSWarning = dnsWarning + return updated, nil } func (s *Service) Delete(ctx context.Context, id int64) error { @@ -296,6 +310,29 @@ func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) { return NewCloudflareAPI(accountID, token), nil } +// ensureTunnelDNS 为所有 hostname 幂等创建或更新指向隧道的 CNAME 记录。 +func (s *Service) ensureTunnelDNS(ctx context.Context, api *CloudflareAPI, tunnelID string, hostnames []string) error { + target := tunnelID + ".cfargotunnel.com" + var firstErr error + for _, raw := range hostnames { + hostname := strings.ToLower(strings.TrimSpace(raw)) + if hostname == "" { + continue + } + zoneID, _, err := api.FindZone(ctx, hostname) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if err := api.EnsureCNAME(ctx, zoneID, hostname, target); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + // VerifyAPIToken 用 Account ID + API Token 调用 Cloudflare 账号级 tokens/verify 接口。 // accountID / input 为空时回退到应用配置中已保存的值。 func (s *Service) VerifyAPIToken(ctx context.Context, accountID, input string) (string, error) { diff --git a/web/package-lock.json b/web/package-lock.json index 86d310f..2204079 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.6.9", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.6.9", + "version": "1.7.0", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index 53f0218..143f3a0 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.6.9", + "version": "1.7.0", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e22b148..2c3b0c0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1066,6 +1066,7 @@ export interface CfTunnel { auto_start: boolean hostnames?: string[] origin_service?: string + dns_warning?: string created_at: string updated_at: string } diff --git a/web/src/views/CloudflareView.vue b/web/src/views/CloudflareView.vue index 65150a2..d316fbb 100644 --- a/web/src/views/CloudflareView.vue +++ b/web/src/views/CloudflareView.vue @@ -139,7 +139,7 @@ {{ tokenTestResult }} -

Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道,不再需要手工填写 Tunnel Token。

+

Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限。

默认网络设置 @@ -310,13 +310,15 @@ async function saveTunnel() { } saving.value = true try { - if (editing.value) { - await api.updateCfTunnel(editing.value.id, payload) - } else { - await api.createCfTunnel(payload) - } + const result = editing.value + ? await api.updateCfTunnel(editing.value.id, payload) + : await api.createCfTunnel(payload) editorVisible.value = false - message.success('隧道已保存') + if (result.dns_warning) { + message.warning(result.dns_warning) + } else { + message.success('隧道已保存') + } await load() } catch (error) { message.error(error instanceof Error ? error.message : '保存隧道失败') diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index eda7a96..e7b96eb 100644 --- a/web/src/views/ExampleView.vue +++ b/web/src/views/ExampleView.vue @@ -8,7 +8,7 @@ - + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -19,7 +19,7 @@ {{ verificationRound }}
- 看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.9。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.7.0。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.6.9。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.9。
  • -
  • 验证标识更新为 v1.6.9-example-page。
  • +
  • 页面版本提示更新为 v1.7.0。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.7.0。
  • +
  • 验证标识更新为 v1.7.0-example-page。
@@ -87,8 +87,8 @@ const message = useMessage() const version = ref(__APP_VERSION__) const checkedAt = ref('') const refreshing = ref(false) -const verificationRound = 'v1.6.9' -const verificationMarker = 'v1.6.9-example-page' +const verificationRound = 'v1.7.0' +const verificationMarker = 'v1.7.0-example-page' async function refreshVersion() { refreshing.value = true