Files
-Fonu/internal/cloudflared/cloudflare_api.go
T

150 lines
4.5 KiB
Go
Raw Normal View History

package cloudflared
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"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)
}
// 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)
}