feat(cloudflare): 改用 Account ID + API Token 创建管理隧道并发布 v1.6.8
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-28 18:23:38 +08:00
parent 7acf13583b
commit 8f78aad19b
15 changed files with 308 additions and 237 deletions
+74 -150
View File
@@ -2,15 +2,11 @@ package cloudflared
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/fsutil"
@@ -73,20 +69,29 @@ func (s *Service) Get(ctx context.Context, id int64) (Tunnel, error) {
// Create 解析 token、生成 creds.json 与 config.yml,并写入数据库。
func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
if strings.TrimSpace(in.Mode) == "" {
in.Mode = ModeAccountLocal
}
if err := validateInput(in); err != nil {
return Tunnel{}, err
}
in.Network = s.applyDefaultNetwork(ctx, in.Network)
token, err := s.resolveToken(ctx, in.Token)
api, err := s.cloudflareAPI(ctx)
if err != nil {
return Tunnel{}, err
}
accountTag, tunnelID, tunnelSecret, err := DecodeToken(token)
in.Network = s.applyDefaultNetwork(ctx, in.Network)
tunnelID, tunnelToken, err := api.CreateTunnel(ctx, in.Name)
if err != nil {
return Tunnel{}, err
}
accountTag, decodedID, tunnelSecret, err := DecodeToken(tunnelToken)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, fmt.Errorf("Cloudflare 返回的隧道凭据无法解析: %w", err)
}
dir := s.tunnelDir(in.Name)
if err := os.MkdirAll(dir, 0o750); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
configPath := filepath.Join(dir, "config.yml")
@@ -97,21 +102,29 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
configContent := BuildConfig(tunnelID, credsPath, in.Network, ingress)
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, tunnelID, tunnelSecret)), 0o600); err != nil {
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(configPath, []byte(configContent), 0o640); err != nil {
if err := fsutil.WriteFileAtomic(configPath, []byte(BuildConfig(decodedID, credsPath, in.Network, ingress)), 0o640); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(token)
encrypted, err := s.secretBox.Encrypt(tunnelToken)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
return s.store.Create(ctx, in, tunnelID, accountTag, encrypted, "", configPath, credsPath, logPath)
tunnel, err := s.store.Create(ctx, in, decodedID, accountTag, encrypted, "", configPath, credsPath, logPath)
if err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
_ = api.PutIngress(ctx, decodedID, ingress)
return tunnel, nil
}
// Update 更新隧道配置;token 留空时保留原凭据。
// Update 更新隧道配置;凭据由 Cloudflare API 创建时生成,编辑不修改。
func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, error) {
current, err := s.store.Get(ctx, id)
if err != nil {
@@ -119,30 +132,20 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel,
}
_ = s.manager.Stop(id)
in.Network = s.applyDefaultNetwork(ctx, in.Network)
accountTag, tunnelID := current.AccountTag, current.TunnelID
encrypted := current.CredentialsEnc
if token := strings.TrimSpace(in.Token); token != "" {
accountTag, tunnelID, tunnelSecret, err := DecodeToken(token)
if err != nil {
return Tunnel{}, err
}
encrypted, err = s.secretBox.Encrypt(token)
if err != nil {
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(current.CredsPath, []byte(CredentialsJSON(accountTag, tunnelID, tunnelSecret)), 0o600); err != nil {
return Tunnel{}, err
}
}
origin := strings.TrimSpace(in.OriginService)
if origin == "" {
origin = defaultOriginService
}
configContent := BuildConfig(tunnelID, current.CredsPath, in.Network, BuildIngress(in.Hostnames, origin))
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(configContent), 0o640); err != nil {
ingress := BuildIngress(in.Hostnames, origin)
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(BuildConfig(current.TunnelID, current.CredsPath, in.Network, ingress)), 0o640); err != nil {
return Tunnel{}, err
}
return s.store.Update(ctx, id, in, tunnelID, accountTag, encrypted, current.CertPEMEnc,
if current.Mode == ModeAccountLocal && current.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
_ = api.PutIngress(ctx, current.TunnelID, ingress)
}
}
return s.store.Update(ctx, id, in, current.TunnelID, current.AccountTag, current.CredentialsEnc, current.CertPEMEnc,
current.ConfigPath, current.CredsPath, current.LogPath)
}
@@ -152,6 +155,11 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
return err
}
_ = s.manager.Stop(id)
if current.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
_ = api.DeleteTunnel(ctx, current.TunnelID)
}
}
if dir := filepath.Dir(current.ConfigPath); dir != "" && strings.HasPrefix(dir, s.cfg.DataDir) {
_ = os.RemoveAll(dir)
}
@@ -207,7 +215,7 @@ func (s *Service) AppSettings(ctx context.Context) (AppSettings, error) {
if err != nil {
return AppSettings{}, err
}
out := AppSettings{Mirror: row.Mirror, Network: row.Network}
out := AppSettings{AccountID: row.AccountID, Mirror: row.Mirror, Network: row.Network}
if row.TokenEnc != "" {
out.DefaultTokenConfigured = true
if plain, err := s.secretBox.Decrypt(row.TokenEnc); err == nil {
@@ -229,16 +237,11 @@ func (s *Service) SaveAppSettings(ctx context.Context, in AppSettingsInput) (App
if err != nil {
return AppSettings{}, err
}
tokenEnc := row.TokenEnc
if token := strings.TrimSpace(in.Token); token != "" {
if _, _, _, err := DecodeToken(token); err != nil {
return AppSettings{}, err
}
tokenEnc, err = s.secretBox.Encrypt(token)
if err != nil {
return AppSettings{}, err
}
accountID := strings.TrimSpace(in.AccountID)
if accountID == "" {
accountID = row.AccountID
}
tokenEnc := row.TokenEnc
apiTokenEnc := row.APITokenEnc
if apiToken := strings.TrimSpace(in.APIToken); apiToken != "" {
apiTokenEnc, err = s.secretBox.Encrypt(apiToken)
@@ -257,7 +260,7 @@ func (s *Service) SaveAppSettings(ctx context.Context, in AppSettingsInput) (App
if strings.TrimSpace(network.TransportProtocol) == "" {
network = row.Network
}
if err := s.store.SaveSettings(ctx, tokenEnc, apiTokenEnc, mirror, network); err != nil {
if err := s.store.SaveSettings(ctx, accountID, tokenEnc, apiTokenEnc, mirror, network); err != nil {
return AppSettings{}, err
}
return s.AppSettings(ctx)
@@ -274,33 +277,37 @@ func (s *Service) applyDefaultNetwork(ctx context.Context, network NetworkSettin
return row.Network
}
func (s *Service) resolveToken(ctx context.Context, input string) (string, error) {
token := strings.TrimSpace(input)
if token != "" {
return token, nil
func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return nil, err
}
accountID := strings.TrimSpace(row.AccountID)
if accountID == "" {
return nil, fmt.Errorf("请先在「应用配置」中填写 Account ID")
}
if row.APITokenEnc == "" {
return nil, fmt.Errorf("请先在「应用配置」中保存 API Token")
}
token, err := s.secretBox.Decrypt(row.APITokenEnc)
if err != nil {
return nil, fmt.Errorf("API Token 解密失败: %w", err)
}
return NewCloudflareAPI(accountID, token), nil
}
// VerifyAPIToken 用 Account ID + API Token 调用 Cloudflare 账号级 tokens/verify 接口。
func (s *Service) VerifyAPIToken(ctx context.Context, input string) (string, error) {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return "", err
}
if row.TokenEnc == "" {
return "", fmt.Errorf("请填写 token,或先在「应用配置」中保存默认 token")
accountID := strings.TrimSpace(row.AccountID)
if accountID == "" {
return "", fmt.Errorf("请先填写 Account ID")
}
plain, err := s.secretBox.Decrypt(row.TokenEnc)
if err != nil {
return "", fmt.Errorf("默认 token 解密失败: %w", err)
}
return plain, nil
}
// VerifyAPIToken 调用 Cloudflare 官方 tokens/verify 接口验证 API Token(cfat_...)。
func (s *Service) VerifyAPIToken(ctx context.Context, input string) (string, error) {
token := strings.TrimSpace(input)
if token == "" {
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return "", err
}
if row.APITokenEnc == "" {
return "", fmt.Errorf("请填写 API Token(cfat_...),或先在应用配置中保存")
}
@@ -309,95 +316,12 @@ func (s *Service) VerifyAPIToken(ctx context.Context, input string) (string, err
return "", fmt.Errorf("API Token 解密失败: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("请求 Cloudflare API 失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var parsed struct {
Success bool `json:"success"`
Result struct {
Status string `json:"status"`
} `json:"result"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("Cloudflare API 响应格式无效(HTTP %d)", resp.StatusCode)
}
if !parsed.Success || parsed.Result.Status != "active" {
message := "API Token 无效或未激活"
if len(parsed.Errors) > 0 && strings.TrimSpace(parsed.Errors[0].Message) != "" {
message = parsed.Errors[0].Message
}
return "", fmt.Errorf("%s", message)
}
return fmt.Sprintf("API Token 有效(状态:%s)", parsed.Result.Status), nil
return NewCloudflareAPI(accountID, token).VerifyToken(ctx)
}
// TestToken 用 cloudflared 临时启动一次连接,验证 token 能否注册到 Cloudflare 边缘。
func (s *Service) TestToken(ctx context.Context, input, kind string) (string, error) {
if kind == "api" || strings.HasPrefix(strings.TrimSpace(input), "cfat_") {
return s.VerifyAPIToken(ctx, input)
}
token, err := s.resolveToken(ctx, input)
if err != nil {
return "", err
}
accountTag, tunnelID, tunnelSecret, err := DecodeToken(token)
if err != nil {
return "", err
}
binaryPath := s.binaryPath()
if _, err := os.Stat(binaryPath); err != nil {
return "", fmt.Errorf("cloudflared 未安装,请先在应用配置中下载")
}
baseDir := filepath.Join(s.cfg.DataDir, "cloudflared")
if err := os.MkdirAll(baseDir, 0o755); err != nil {
return "", err
}
dir, err := os.MkdirTemp(baseDir, "token-test-")
if err != nil {
return "", err
}
defer os.RemoveAll(dir)
credsPath := filepath.Join(dir, "creds.json")
configPath := filepath.Join(dir, "config.yml")
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, tunnelID, tunnelSecret)), 0o600); err != nil {
return "", err
}
ingress := BuildIngress(nil, defaultOriginService)
if err := fsutil.WriteFileAtomic(configPath, []byte(BuildConfig(tunnelID, credsPath, DefaultNetworkSettings(), ingress)), 0o640); err != nil {
return "", err
}
temp := Tunnel{
ID: -1, Name: "token-test", Mode: ModeTokenLocal,
Network: DefaultNetworkSettings(), LogPath: filepath.Join(dir, "cloudflared.log"),
}
if err := s.manager.Start(temp, binaryPath, configPath, ""); err != nil {
return "", err
}
defer func() { _ = s.manager.Stop(-1) }()
deadline := time.Now().Add(25 * time.Second)
for time.Now().Before(deadline) {
status := s.manager.Status(-1)
if status.Status == "connected" {
return fmt.Sprintf("连接成功,隧道 %s 已注册到 Cloudflare 边缘", tunnelID), nil
}
if status.Status == "failed" {
return "", fmt.Errorf("连接失败: %s", status.LastError)
}
time.Sleep(500 * time.Millisecond)
}
return "", fmt.Errorf("连接超时:25 秒内未注册到 Cloudflare 边缘,请检查网络或 token")
// TestToken 验证 Account ID + API Token 是否可用。
func (s *Service) TestToken(ctx context.Context, input, _ string) (string, error) {
return s.VerifyAPIToken(ctx, input)
}
// attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
@@ -503,9 +427,9 @@ func validateInput(in CreateInput) error {
return fmt.Errorf("隧道名称不能为空")
}
switch in.Mode {
case ModeTokenLocal, ModeTokenRemote:
case ModeAccountLocal, ModeTokenLocal, ModeTokenRemote:
default:
return fmt.Errorf("暂不支持该模式,当前仅支持 token-local / token-remote")
return fmt.Errorf("不支持的隧道模式: %s", in.Mode)
}
return nil
}