2026-09-28 18:23:38 +08:00
|
|
|
|
package cloudflared
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
2026-09-28 18:47:37 +08:00
|
|
|
|
"net/url"
|
2026-09-28 18:23:38 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const cloudflareAPIBase = "https://api.cloudflare.com/client/v4"
|
|
|
|
|
|
|
|
|
|
|
|
// CloudflareAPI 用 Account ID + API Token 调用 Cloudflare API。
|
|
|
|
|
|
type CloudflareAPI struct {
|
|
|
|
|
|
AccountID string
|
|
|
|
|
|
APIToken string
|
|
|
|
|
|
client *http.Client
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewCloudflareAPI(accountID, apiToken string) *CloudflareAPI {
|
|
|
|
|
|
return &CloudflareAPI{
|
|
|
|
|
|
AccountID: strings.TrimSpace(accountID),
|
|
|
|
|
|
APIToken: strings.TrimSpace(apiToken),
|
|
|
|
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type cfAPIError struct {
|
|
|
|
|
|
Code int `json:"code"`
|
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type cfEnvelope struct {
|
|
|
|
|
|
Success bool `json:"success"`
|
|
|
|
|
|
Result json.RawMessage `json:"result"`
|
|
|
|
|
|
Errors []cfAPIError `json:"errors"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *CloudflareAPI) do(ctx context.Context, method, path string, payload any, out any) error {
|
|
|
|
|
|
var body io.Reader
|
|
|
|
|
|
if payload != nil {
|
|
|
|
|
|
data, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
body = bytes.NewReader(data)
|
|
|
|
|
|
}
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, cloudflareAPIBase+path, body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+a.APIToken)
|
|
|
|
|
|
if payload != nil {
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
}
|
|
|
|
|
|
resp, err := a.client.Do(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("请求 Cloudflare API 失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
var envelope cfEnvelope
|
|
|
|
|
|
if err := json.Unmarshal(data, &envelope); err != nil {
|
|
|
|
|
|
return fmt.Errorf("Cloudflare API 响应格式无效(HTTP %d)", resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !envelope.Success {
|
|
|
|
|
|
message := "Cloudflare API 调用失败"
|
|
|
|
|
|
if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" {
|
|
|
|
|
|
message = envelope.Errors[0].Message
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Errorf("%s(HTTP %d)", message, resp.StatusCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
if out != nil && len(envelope.Result) > 0 {
|
|
|
|
|
|
if err := json.Unmarshal(envelope.Result, out); err != nil {
|
|
|
|
|
|
return fmt.Errorf("Cloudflare API 结果解析失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// VerifyToken 调用账号级 tokens/verify 接口验证 API Token。
|
|
|
|
|
|
func (a *CloudflareAPI) VerifyToken(ctx context.Context) (string, error) {
|
|
|
|
|
|
if a.AccountID == "" {
|
|
|
|
|
|
return "", fmt.Errorf("请先填写 Account ID")
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.APIToken == "" {
|
|
|
|
|
|
return "", fmt.Errorf("请先填写 API Token(cfat_...)")
|
|
|
|
|
|
}
|
|
|
|
|
|
var result struct {
|
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/tokens/verify", nil, &result); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.Status != "active" {
|
|
|
|
|
|
return "", fmt.Errorf("API Token 状态为 %s,不是 active", result.Status)
|
|
|
|
|
|
}
|
|
|
|
|
|
return "API Token 有效(状态:active)", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CreateTunnel 创建一条 config_src=local 的命名隧道,并返回隧道 ID 与凭据 token。
|
|
|
|
|
|
func (a *CloudflareAPI) CreateTunnel(ctx context.Context, name string) (tunnelID, token string, err error) {
|
|
|
|
|
|
var result struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Token string `json:"token"`
|
|
|
|
|
|
}
|
|
|
|
|
|
payload := map[string]any{"name": name, "config_src": "local"}
|
|
|
|
|
|
if err := a.do(ctx, http.MethodPost, "/accounts/"+a.AccountID+"/cfd_tunnel", payload, &result); err != nil {
|
|
|
|
|
|
return "", "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.ID == "" {
|
|
|
|
|
|
return "", "", fmt.Errorf("Cloudflare 未返回隧道 ID")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(result.Token) != "" {
|
|
|
|
|
|
return result.ID, result.Token, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
token, err = a.TunnelToken(ctx, result.ID)
|
|
|
|
|
|
return result.ID, token, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TunnelToken 读取指定隧道的凭据 token。
|
|
|
|
|
|
func (a *CloudflareAPI) TunnelToken(ctx context.Context, tunnelID string) (string, error) {
|
|
|
|
|
|
var token string
|
|
|
|
|
|
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/token", nil, &token); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(token) == "" {
|
|
|
|
|
|
return "", fmt.Errorf("Cloudflare 未返回隧道凭据")
|
|
|
|
|
|
}
|
|
|
|
|
|
return token, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PutIngress 把本地 ingress 推送到 Cloudflare 云端配置。
|
|
|
|
|
|
func (a *CloudflareAPI) PutIngress(ctx context.Context, tunnelID string, ingress []IngressRule) error {
|
|
|
|
|
|
return a.do(ctx, http.MethodPut, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", map[string]any{
|
|
|
|
|
|
"config": map[string]any{"ingress": ingress},
|
|
|
|
|
|
}, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 11:32:54 +08:00
|
|
|
|
// GetIngress 读取云端隧道的 ingress 配置。
|
|
|
|
|
|
func (a *CloudflareAPI) GetIngress(ctx context.Context, tunnelID string) ([]IngressRule, error) {
|
|
|
|
|
|
var result struct {
|
|
|
|
|
|
Config struct {
|
|
|
|
|
|
Ingress []IngressRule `json:"ingress"`
|
|
|
|
|
|
} `json:"config"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", nil, &result); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return result.Config.Ingress, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 18:23:38 +08:00
|
|
|
|
// DeleteTunnel 删除云端隧道。
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-09-28 18:47:37 +08:00
|
|
|
|
|
|
|
|
|
|
// Zone 是 Cloudflare 的域名区域。
|
|
|
|
|
|
type Zone struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 11:32:54 +08:00
|
|
|
|
// ListZones 返回当前 API Token 可访问的 Zone。
|
|
|
|
|
|
func (a *CloudflareAPI) ListZones(ctx context.Context) ([]Zone, error) {
|
|
|
|
|
|
var zones []Zone
|
|
|
|
|
|
if err := a.do(ctx, http.MethodGet, "/zones?per_page=50", nil, &zones); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return zones, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 18:47:37 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-01 11:32:54 +08:00
|
|
|
|
return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认该域名的根域名已加入当前 Account ID,且 API Token 的 Zone Resources 包含它,并具备 Zone:Read 权限", hostname)
|
2026-09-28 18:47:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 11:32:54 +08:00
|
|
|
|
// EnsureCNAME 幂等创建指向隧道的 CNAME 记录,并开启代理(橙云)。
|
|
|
|
|
|
// 已存在且目标一致的记录会被接管;指向其他目标的记录不会被覆盖。
|
|
|
|
|
|
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) (string, error) {
|
2026-09-28 18:47:37 +08:00
|
|
|
|
var records []struct {
|
2026-10-01 11:32:54 +08:00
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Content string `json:"content"`
|
2026-09-28 18:47:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
path := "/zones/" + zoneID + "/dns_records?type=CNAME&name=" + url.QueryEscape(hostname)
|
|
|
|
|
|
if err := a.do(ctx, http.MethodGet, path, nil, &records); err != nil {
|
2026-10-01 11:32:54 +08:00
|
|
|
|
return "", err
|
2026-09-28 18:47:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
|
"type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(records) > 0 {
|
2026-10-01 11:32:54 +08:00
|
|
|
|
if !strings.EqualFold(strings.TrimSpace(records[0].Content), strings.TrimSpace(target)) {
|
|
|
|
|
|
return "", fmt.Errorf("DNS 记录 %s 已指向其他目标,未自动覆盖", hostname)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
return records[0].ID, nil
|
2026-09-28 18:47:37 +08:00
|
|
|
|
}
|
2026-10-01 11:32:54 +08:00
|
|
|
|
var created struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, &created); err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(created.ID) == "" {
|
|
|
|
|
|
return "", fmt.Errorf("Cloudflare 未返回 DNS 记录 ID")
|
|
|
|
|
|
}
|
|
|
|
|
|
return created.ID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DeleteDNSRecord 删除一条明确由 Fonu 接管的 DNS 记录。
|
|
|
|
|
|
func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
|
|
|
|
|
|
return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type accessApp struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
Domain string `json:"domain"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// HasAccessForHostname 只读检测域名是否已有 Access 应用;Token 无权限时返回错误,不修改任何配置。
|
|
|
|
|
|
func (a *CloudflareAPI) HasAccessForHostname(ctx context.Context, hostname string) (bool, string, error) {
|
|
|
|
|
|
var apps []accessApp
|
|
|
|
|
|
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/access/apps?per_page=100", nil, &apps); err != nil {
|
|
|
|
|
|
return false, "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
wanted := strings.ToLower(strings.TrimSpace(hostname))
|
|
|
|
|
|
for _, app := range apps {
|
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(app.Domain), wanted) {
|
|
|
|
|
|
return true, app.Name, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false, "", nil
|
2026-09-28 18:47:37 +08:00
|
|
|
|
}
|