From 8f78aad19bb1519c29312da87491bfb6edc3f56f Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Mon, 28 Sep 2026 18:23:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(cloudflare):=20=E6=94=B9=E7=94=A8=20Accoun?= =?UTF-8?q?t=20ID=20+=20API=20Token=20=E5=88=9B=E5=BB=BA=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=9A=A7=E9=81=93=E5=B9=B6=E5=8F=91=E5=B8=83=20v1.6.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 19 +++ VERSION | 2 +- docs/README.md | 2 +- docs/system-upgrade.md | 8 +- internal/cloudflared/cloudflare_api.go | 149 ++++++++++++++++ internal/cloudflared/model.go | 10 +- internal/cloudflared/service.go | 224 ++++++++----------------- internal/cloudflared/settings_test.go | 23 +-- internal/cloudflared/store.go | 15 +- migrations/033_cf_account_id.sql | 2 + web/package-lock.json | 4 +- web/package.json | 2 +- web/src/api/types.ts | 2 + web/src/views/CloudflareView.vue | 67 +++----- web/src/views/ExampleView.vue | 16 +- 15 files changed, 308 insertions(+), 237 deletions(-) create mode 100644 internal/cloudflared/cloudflare_api.go create mode 100644 migrations/033_cf_account_id.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index c342d8e..3264c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [v1.6.8] - 2026-09-28 + +### 变更 + +- **Cloudflare 凭据模型改为 Account ID + API Token**:应用配置不再要求 Tunnel Token;Fonu 用 Account ID + API Token 调用 Cloudflare API: + - 「测试连接」调用 `GET /accounts/{account_id}/tokens/verify` + - 新建隧道时自动创建 `config_src=local` 的命名隧道、获取凭据、生成本地 `config.yml` 与 `creds.json` + - 删除隧道时同步删除云端隧道;编辑时同步推送 ingress +- 隧道弹窗去掉 Token 与模式选择,只保留名称、暴露域名、回源 service 与自动启动 +- 新增 `cf_settings.account_id` 字段(迁移 `033`) + +### 变更 + +- 版本号提升到 1.6.8;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + ## [v1.6.7] - 2026-09-28 ### 新增 diff --git a/VERSION b/VERSION index 400084b..d8c5e72 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.7 +1.6.8 diff --git a/docs/README.md b/docs/README.md index a2ea05a..2f00491 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.6.7) +> 最近更新:2026-09-28(v1.6.8) ## 方案与设计(本目录) diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index c668174..bd771c5 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu ## 10. 在线升级验证 -示例页面用于验证升级结果。v1.6.7 的验证流程: +示例页面用于验证升级结果。v1.6.8 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.7`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.8`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.6.7`; - - 页面显示验证标识 `v1.6.7-example-page`; + - 页面显示的版本为 `1.6.8`; + - 页面显示验证标识 `v1.6.8-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/cloudflared/cloudflare_api.go b/internal/cloudflared/cloudflare_api.go new file mode 100644 index 0000000..272724b --- /dev/null +++ b/internal/cloudflared/cloudflare_api.go @@ -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) +} diff --git a/internal/cloudflared/model.go b/internal/cloudflared/model.go index 63d46ce..455d3ea 100644 --- a/internal/cloudflared/model.go +++ b/internal/cloudflared/model.go @@ -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 是隧道运行状态与指标快照。 diff --git a/internal/cloudflared/service.go b/internal/cloudflared/service.go index 28e7caa..7952615 100644 --- a/internal/cloudflared/service.go +++ b/internal/cloudflared/service.go @@ -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 } diff --git a/internal/cloudflared/settings_test.go b/internal/cloudflared/settings_test.go index 9f80c60..5857452 100644 --- a/internal/cloudflared/settings_test.go +++ b/internal/cloudflared/settings_test.go @@ -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) } } diff --git a/internal/cloudflared/store.go b/internal/cloudflared/store.go index 6639411..18c9fda 100644 --- a/internal/cloudflared/store.go +++ b/internal/cloudflared/store.go @@ -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 } diff --git a/migrations/033_cf_account_id.sql b/migrations/033_cf_account_id.sql new file mode 100644 index 0000000..cbef781 --- /dev/null +++ b/migrations/033_cf_account_id.sql @@ -0,0 +1,2 @@ +-- Cloudflare 账号 ID:与 API Token 一起用于创建与管理隧道。 +ALTER TABLE cf_settings ADD COLUMN account_id TEXT NOT NULL DEFAULT ''; diff --git a/web/package-lock.json b/web/package-lock.json index 73689bb..3f40bd3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.6.7", + "version": "1.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.6.7", + "version": "1.6.8", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index 090fede..3d0277f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.6.7", + "version": "1.6.8", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1175245..e22b148 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1100,6 +1100,7 @@ export interface CfTunnelPayload { } export interface CfAppSettings { + account_id: string default_token_configured: boolean default_token_masked: string api_token_configured: boolean @@ -1109,6 +1110,7 @@ export interface CfAppSettings { } export interface CfAppSettingsInput { + account_id: string token: string api_token: string mirror: string diff --git a/web/src/views/CloudflareView.vue b/web/src/views/CloudflareView.vue index cba73b8..7b7adf3 100644 --- a/web/src/views/CloudflareView.vue +++ b/web/src/views/CloudflareView.vue @@ -39,19 +39,18 @@
- - {{ modeHint }} + + 隧道由 Cloudflare API 创建;Fonu 负责本地配置、进程、日志与健康。 - - 还没有默认 Token,请先到「应用配置」保存,再创建隧道。 + + 请先在「应用配置」中填写 Account ID 与 API Token,再创建隧道。 基本信息 - 暴露规则 - + @@ -59,14 +58,11 @@ - - 云端规则模式下,本地域名与回源配置不生效,请在 Cloudflare 面板管理 ingress。 - 运行 -

Token 与网络调优统一在「应用配置」中管理,保存后会应用到新建隧道。

+

网络调优统一在「应用配置」中管理,保存后会应用到新建隧道。

- + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -19,7 +19,7 @@ {{ verificationRound }}
- 看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.7。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.8。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.6.7。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.7。
  • -
  • 验证标识更新为 v1.6.7-example-page。
  • +
  • 页面版本提示更新为 v1.6.8。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.8。
  • +
  • 验证标识更新为 v1.6.8-example-page。
@@ -87,8 +87,8 @@ const message = useMessage() const version = ref(__APP_VERSION__) const checkedAt = ref('') const refreshing = ref(false) -const verificationRound = 'v1.6.7' -const verificationMarker = 'v1.6.7-example-page' +const verificationRound = 'v1.6.8' +const verificationMarker = 'v1.6.8-example-page' async function refreshVersion() { refreshing.value = true