feat(cloudflare): 改用 Account ID + API Token 创建管理隧道并发布 v1.6.8
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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)
|
||||
}
|
||||
@@ -72,6 +72,7 @@ type CreateInput struct {
|
||||
|
||||
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。
|
||||
type AppSettings struct {
|
||||
AccountID string `json:"account_id"`
|
||||
DefaultTokenConfigured bool `json:"default_token_configured"`
|
||||
DefaultTokenMasked string `json:"default_token_masked"`
|
||||
APITokenConfigured bool `json:"api_token_configured"`
|
||||
@@ -82,10 +83,11 @@ type AppSettings struct {
|
||||
|
||||
// AppSettingsInput 是保存应用配置的入参;Token 留空表示保留原值。
|
||||
type AppSettingsInput struct {
|
||||
Token string `json:"token"`
|
||||
APIToken string `json:"api_token"`
|
||||
Mirror string `json:"mirror"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
AccountID string `json:"account_id"`
|
||||
Token string `json:"token"`
|
||||
APIToken string `json:"api_token"`
|
||||
Mirror string `json:"mirror"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
}
|
||||
|
||||
// RuntimeStatus 是隧道运行状态与指标快照。
|
||||
|
||||
+74
-150
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package cloudflared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
@@ -13,7 +12,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
)
|
||||
|
||||
func TestAppSettingsDefaultToken(t *testing.T) {
|
||||
func TestAppSettingsAccountAndAPIToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
@@ -28,25 +27,17 @@ func TestAppSettingsDefaultToken(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
token := base64.StdEncoding.EncodeToString([]byte(`{"a":"acc-1","t":"tun-1","s":"sec-1"}`))
|
||||
|
||||
settings, err := svc.SaveAppSettings(context.Background(), AppSettingsInput{
|
||||
Token: token, Mirror: "gh-proxy-org", Network: DefaultNetworkSettings(),
|
||||
AccountID: "acc-1", APIToken: "cfat_test_token", Mirror: "gh-proxy-org", Network: DefaultNetworkSettings(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !settings.DefaultTokenConfigured || settings.DefaultTokenMasked == "" {
|
||||
t.Fatalf("默认 token 应标记为已配置并返回掩码:%#v", settings)
|
||||
if settings.AccountID != "acc-1" || !settings.APITokenConfigured || settings.APITokenMasked == "" {
|
||||
t.Fatalf("Account ID 与 API Token 应保存并回显掩码:%#v", settings)
|
||||
}
|
||||
|
||||
tunnel, err := svc.Create(context.Background(), CreateInput{
|
||||
Name: "demo", Mode: ModeTokenLocal, Hostnames: []string{"a.example.com"}, Network: DefaultNetworkSettings(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("应能使用默认 token 创建隧道: %v", err)
|
||||
}
|
||||
if tunnel.TunnelID != "tun-1" || tunnel.AccountTag != "acc-1" {
|
||||
t.Fatalf("默认 token 解析结果不符:%#v", tunnel)
|
||||
api, err := svc.cloudflareAPI(context.Background())
|
||||
if err != nil || api.AccountID != "acc-1" || api.APIToken != "cfat_test_token" {
|
||||
t.Fatalf("cloudflareAPI 构造失败:%v %#v", err, api)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri
|
||||
|
||||
// settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。
|
||||
type settingsRow struct {
|
||||
AccountID string
|
||||
TokenEnc string
|
||||
APITokenEnc string
|
||||
Mirror string
|
||||
@@ -104,30 +105,30 @@ type settingsRow struct {
|
||||
}
|
||||
|
||||
func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) {
|
||||
var tokenEnc, apiTokenEnc, mirror, networkJSON string
|
||||
var accountID, tokenEnc, apiTokenEnc, mirror, networkJSON string
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(default_token_enc, ''), COALESCE(api_token_enc, ''),
|
||||
SELECT COALESCE(account_id, ''), COALESCE(default_token_enc, ''), COALESCE(api_token_enc, ''),
|
||||
COALESCE(mirror, 'official'), COALESCE(network_json, '{}')
|
||||
FROM cf_settings WHERE id = 1
|
||||
`).Scan(&tokenEnc, &apiTokenEnc, &mirror, &networkJSON)
|
||||
`).Scan(&accountID, &tokenEnc, &apiTokenEnc, &mirror, &networkJSON)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return settingsRow{Mirror: "official", Network: DefaultNetworkSettings()}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return settingsRow{}, err
|
||||
}
|
||||
row := settingsRow{TokenEnc: tokenEnc, APITokenEnc: apiTokenEnc, Mirror: mirror, Network: DefaultNetworkSettings()}
|
||||
row := settingsRow{AccountID: accountID, TokenEnc: tokenEnc, APITokenEnc: apiTokenEnc, Mirror: mirror, Network: DefaultNetworkSettings()}
|
||||
_ = json.Unmarshal([]byte(networkJSON), &row.Network)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveSettings(ctx context.Context, tokenEnc, apiTokenEnc, mirror string, network NetworkSettings) error {
|
||||
func (s *Store) SaveSettings(ctx context.Context, accountID, tokenEnc, apiTokenEnc, mirror string, network NetworkSettings) error {
|
||||
networkJSON, _ := json.Marshal(network)
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE cf_settings
|
||||
SET default_token_enc = ?, api_token_enc = ?, mirror = ?, network_json = ?, updated_at = datetime('now')
|
||||
SET account_id = ?, default_token_enc = ?, api_token_enc = ?, mirror = ?, network_json = ?, updated_at = datetime('now')
|
||||
WHERE id = 1
|
||||
`, tokenEnc, apiTokenEnc, mirror, string(networkJSON))
|
||||
`, accountID, tokenEnc, apiTokenEnc, mirror, string(networkJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user