feat(cloudflare): 自动创建隧道 DNS 记录并发布 v1.7.0
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-28 18:47:37 +08:00
parent af07ebd462
commit 1434cfd72b
12 changed files with 126 additions and 25 deletions
+19
View File
@@ -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:
- 目标为 `<tunnel-id>.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
### 修复
+1 -1
View File
@@ -1 +1 @@
1.6.9
1.7.0
+1 -1
View File
@@ -1,7 +1,7 @@
# Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-28(v1.6.9)
> 最近更新:2026-09-28(v1.7.0)
## 方案与设计(本目录)
+4 -4
View File
@@ -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`;
- 页面显示独立的「本次升级验证」区块;
- 「升级验证」显示为「通过」。
+41
View File
@@ -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)
}
+1
View File
@@ -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"`
}
+38 -1
View File
@@ -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) {
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+1
View File
@@ -1066,6 +1066,7 @@ export interface CfTunnel {
auto_start: boolean
hostnames?: string[]
origin_service?: string
dns_warning?: string
created_at: string
updated_at: string
}
+8 -6
View File
@@ -139,7 +139,7 @@
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
{{ tokenTestResult }}
</n-alert>
<p class="cf-hint">Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道,不再需要手工填写 Tunnel Token。</p>
<p class="cf-hint">Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限。</p>
<n-divider title-placement="left">默认网络设置</n-divider>
<n-form label-placement="top">
@@ -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
if (result.dns_warning) {
message.warning(result.dns_warning)
} else {
message.success('隧道已保存')
}
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存隧道失败')
+8 -8
View File
@@ -8,7 +8,7 @@
</template>
</PageHeader>
<n-alert type="success" :bordered="false" class="example-alert" title="v1.6.9 示例页面验证标识已生效">
<n-alert type="success" :bordered="false" class="example-alert" title="v1.7.0 示例页面验证标识已生效">
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong>
</div>
<div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.9。
看到这个新区块,说明前端资源已经从旧版本切换到 v1.7.0。
</div>
</div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template>
</StatCard>
<StatCard label="页面状态" value="已更新" sub="v1.6.9 前端资源已加载" tone="teal">
<StatCard label="页面状态" value="已更新" sub="v1.7.0 前端资源已加载" tone="teal">
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard>
</div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes">
<li>页面版本提示更新为 v1.6.9。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.6.9。</li>
<li>验证标识更新为 <span class="mono">v1.6.9-example-page</span>。</li>
<li>页面版本提示更新为 v1.7.0。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.7.0。</li>
<li>验证标识更新为 <span class="mono">v1.7.0-example-page</span>。</li>
</ul>
</FonuCard>
@@ -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