feat(cloudflare): 自动创建隧道 DNS 记录并发布 v1.7.0
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user