diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a288f..c342d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [v1.6.7] - 2026-09-28 + +### 新增 + +- **API Token 连接验证**:应用配置的 Token 区域拆分为两个输入: + - 隧道 Token(Tunnel Token):用于 cloudflared 运行 + - API Token(`cfat_...`):用于连接验证,调用 Cloudflare 官方 `GET /user/tokens/verify` 接口 +- 「测试连接」优先使用 API Token;未配置 API Token 时回退到隧道 Token 的临时连接测试;输入以 `cfat_` 开头时也会自动走 API 验证 +- 新增 `cf_settings.api_token_enc` 字段(迁移 `032`),API Token 独立加密存储与掩码回显 + +### 变更 + +- 版本号提升到 1.6.7;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + ## [v1.6.6] - 2026-09-28 ### 修复 diff --git a/VERSION b/VERSION index ec70f75..400084b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.6 +1.6.7 diff --git a/docs/README.md b/docs/README.md index b90057a..a2ea05a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.6.6) +> 最近更新:2026-09-28(v1.6.7) ## 方案与设计(本目录) diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index cff18ca..c668174 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.6 的验证流程: +示例页面用于验证升级结果。v1.6.7 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.6`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.7`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.6.6`; - - 页面显示验证标识 `v1.6.6-example-page`; + - 页面显示的版本为 `1.6.7`; + - 页面显示验证标识 `v1.6.7-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/api/cloudflare_handler.go b/internal/api/cloudflare_handler.go index 9a19fe9..96c3c59 100644 --- a/internal/api/cloudflare_handler.go +++ b/internal/api/cloudflare_handler.go @@ -192,12 +192,13 @@ func (h *CloudflareHandler) SaveAppSettings(w http.ResponseWriter, r *http.Reque func (h *CloudflareHandler) TestToken(w http.ResponseWriter, r *http.Request) { var request struct { Token string `json:"token"` + Kind string `json:"kind"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil { writeError(r, w, http.StatusBadRequest, "请求格式无效") return } - message, err := h.svc.TestToken(r.Context(), request.Token) + message, err := h.svc.TestToken(r.Context(), request.Token, request.Kind) if err != nil { writeError(r, w, http.StatusBadGateway, err.Error()) return diff --git a/internal/cloudflared/model.go b/internal/cloudflared/model.go index 69a333e..63d46ce 100644 --- a/internal/cloudflared/model.go +++ b/internal/cloudflared/model.go @@ -74,15 +74,18 @@ type CreateInput struct { type AppSettings struct { DefaultTokenConfigured bool `json:"default_token_configured"` DefaultTokenMasked string `json:"default_token_masked"` + APITokenConfigured bool `json:"api_token_configured"` + APITokenMasked string `json:"api_token_masked"` Mirror string `json:"mirror"` Network NetworkSettings `json:"network"` } // AppSettingsInput 是保存应用配置的入参;Token 留空表示保留原值。 type AppSettingsInput struct { - Token string `json:"token"` - Mirror string `json:"mirror"` - Network NetworkSettings `json:"network"` + 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 a9a3d3b..28e7caa 100644 --- a/internal/cloudflared/service.go +++ b/internal/cloudflared/service.go @@ -2,8 +2,11 @@ package cloudflared import ( "context" + "encoding/json" "fmt" + "io" "log/slog" + "net/http" "os" "path/filepath" "strings" @@ -211,6 +214,12 @@ func (s *Service) AppSettings(ctx context.Context) (AppSettings, error) { out.DefaultTokenMasked = MaskSecret(plain) } } + if row.APITokenEnc != "" { + out.APITokenConfigured = true + if plain, err := s.secretBox.Decrypt(row.APITokenEnc); err == nil { + out.APITokenMasked = MaskSecret(plain) + } + } return out, nil } @@ -230,6 +239,13 @@ func (s *Service) SaveAppSettings(ctx context.Context, in AppSettingsInput) (App return AppSettings{}, err } } + apiTokenEnc := row.APITokenEnc + if apiToken := strings.TrimSpace(in.APIToken); apiToken != "" { + apiTokenEnc, err = s.secretBox.Encrypt(apiToken) + if err != nil { + return AppSettings{}, err + } + } mirror := strings.TrimSpace(in.Mirror) if mirror == "" { mirror = row.Mirror @@ -241,7 +257,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, mirror, network); err != nil { + if err := s.store.SaveSettings(ctx, tokenEnc, apiTokenEnc, mirror, network); err != nil { return AppSettings{}, err } return s.AppSettings(ctx) @@ -277,8 +293,61 @@ func (s *Service) resolveToken(ctx context.Context, input string) (string, error 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_...),或先在应用配置中保存") + } + token, err = s.secretBox.Decrypt(row.APITokenEnc) + if err != nil { + 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 +} + // TestToken 用 cloudflared 临时启动一次连接,验证 token 能否注册到 Cloudflare 边缘。 -func (s *Service) TestToken(ctx context.Context, input string) (string, error) { +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 diff --git a/internal/cloudflared/store.go b/internal/cloudflared/store.go index a12a6e7..6639411 100644 --- a/internal/cloudflared/store.go +++ b/internal/cloudflared/store.go @@ -97,35 +97,37 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri // settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。 type settingsRow struct { - TokenEnc string - Mirror string - Network NetworkSettings + TokenEnc string + APITokenEnc string + Mirror string + Network NetworkSettings } func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) { - var tokenEnc, mirror, networkJSON string + var tokenEnc, apiTokenEnc, mirror, networkJSON string err := s.db.QueryRowContext(ctx, ` - SELECT COALESCE(default_token_enc, ''), COALESCE(mirror, 'official'), COALESCE(network_json, '{}') + SELECT COALESCE(default_token_enc, ''), COALESCE(api_token_enc, ''), + COALESCE(mirror, 'official'), COALESCE(network_json, '{}') FROM cf_settings WHERE id = 1 - `).Scan(&tokenEnc, &mirror, &networkJSON) + `).Scan(&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, Mirror: mirror, Network: DefaultNetworkSettings()} + row := settingsRow{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, mirror string, network NetworkSettings) error { +func (s *Store) SaveSettings(ctx context.Context, tokenEnc, apiTokenEnc, mirror string, network NetworkSettings) error { networkJSON, _ := json.Marshal(network) _, err := s.db.ExecContext(ctx, ` UPDATE cf_settings - SET default_token_enc = ?, mirror = ?, network_json = ?, updated_at = datetime('now') + SET default_token_enc = ?, api_token_enc = ?, mirror = ?, network_json = ?, updated_at = datetime('now') WHERE id = 1 - `, tokenEnc, mirror, string(networkJSON)) + `, tokenEnc, apiTokenEnc, mirror, string(networkJSON)) return err } diff --git a/migrations/032_cf_api_token.sql b/migrations/032_cf_api_token.sql new file mode 100644 index 0000000..e9ef7ae --- /dev/null +++ b/migrations/032_cf_api_token.sql @@ -0,0 +1,2 @@ +-- Cloudflare API Token(cfat_...):用于连接验证与后续云端同步,与隧道 Token 分开存储。 +ALTER TABLE cf_settings ADD COLUMN api_token_enc TEXT NOT NULL DEFAULT ''; diff --git a/web/package-lock.json b/web/package-lock.json index af4ed4a..73689bb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.6.6", + "version": "1.6.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.6.6", + "version": "1.6.7", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index cec40de..090fede 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.6.6", + "version": "1.6.7", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f16f384..312330d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -512,9 +512,9 @@ export const api = { getCfSettings: () => request('/api/cloudflare/settings'), saveCfSettings: (payload: CfAppSettingsInput) => request('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }), - testCfToken: (token: string) => + testCfToken: (token: string, kind: 'tunnel' | 'api') => request<{ ok: boolean; message: string }>('/api/cloudflare/settings/test', { method: 'POST', - body: JSON.stringify({ token }), + body: JSON.stringify({ token, kind }), }, 60_000), } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d87b179..1175245 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1102,12 +1102,15 @@ export interface CfTunnelPayload { export interface CfAppSettings { default_token_configured: boolean default_token_masked: string + api_token_configured: boolean + api_token_masked: string mirror: string network: CfNetworkSettings } export interface CfAppSettingsInput { token: string + api_token: string mirror: string network: CfNetworkSettings } diff --git a/web/src/views/CloudflareView.vue b/web/src/views/CloudflareView.vue index 01d26cb..cba73b8 100644 --- a/web/src/views/CloudflareView.vue +++ b/web/src/views/CloudflareView.vue @@ -124,12 +124,20 @@ Token 配置 - + + + + @@ -140,7 +148,7 @@ {{ tokenTestResult }} -

保存后,新建隧道时可以留空 token,直接使用这里的默认值。

+

隧道 Token 用于 cloudflared 运行;API Token 用于验证 Cloudflare 账号连接(格式 cfat_...)。测试连接优先使用 API Token。

默认网络设置 @@ -188,10 +196,12 @@ const tokenTestOk = ref(false) const appSettings = ref({ default_token_configured: false, default_token_masked: '', + api_token_configured: false, + api_token_masked: '', mirror: 'official', network: emptyNetwork(), }) -const settingsForm = ref({ token: '', mirror: 'official', network: emptyNetwork() }) +const settingsForm = ref({ token: '', api_token: '', mirror: 'official', network: emptyNetwork() }) const editorVisible = ref(false) const detailVisible = ref(false) const editing = ref(null) @@ -409,7 +419,7 @@ async function loadAppSettings() { try { const result = await api.getCfSettings() appSettings.value = result - settingsForm.value = { token: '', mirror: result.mirror || 'official', network: { ...result.network } } + settingsForm.value = { token: '', api_token: '', mirror: result.mirror || 'official', network: { ...result.network } } } catch (error) { message.error(error instanceof Error ? error.message : '读取应用配置失败') } finally { @@ -420,8 +430,13 @@ async function loadAppSettings() { async function testToken() { tokenTesting.value = true tokenTestResult.value = '' + const apiToken = (settingsForm.value.api_token || '').trim() + const useAPI = apiToken !== '' || appSettings.value.api_token_configured try { - const result = await api.testCfToken(settingsForm.value.token) + const result = await api.testCfToken( + useAPI ? apiToken : (settingsForm.value.token || ''), + useAPI ? 'api' : 'tunnel', + ) tokenTestOk.value = true tokenTestResult.value = result.message || '连接成功' } catch (error) { @@ -437,7 +452,7 @@ async function saveSettings() { try { const result = await api.saveCfSettings(settingsForm.value) appSettings.value = result - settingsForm.value = { token: '', mirror: result.mirror, network: { ...result.network } } + settingsForm.value = { token: '', api_token: '', mirror: result.mirror, network: { ...result.network } } message.success('应用配置已保存') } catch (error) { message.error(error instanceof Error ? error.message : '保存应用配置失败') diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index a53b136..45225f3 100644 --- a/web/src/views/ExampleView.vue +++ b/web/src/views/ExampleView.vue @@ -8,7 +8,7 @@ - + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -19,7 +19,7 @@ {{ verificationRound }}
- 看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.6。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.7。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.6.6。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.6。
  • -
  • 验证标识更新为 v1.6.6-example-page。
  • +
  • 页面版本提示更新为 v1.6.7。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.7。
  • +
  • 验证标识更新为 v1.6.7-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.6' -const verificationMarker = 'v1.6.6-example-page' +const verificationRound = 'v1.6.7' +const verificationMarker = 'v1.6.7-example-page' async function refreshVersion() { refreshing.value = true