diff --git a/CHANGELOG.md b/CHANGELOG.md index d0444ee..c64d7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to this project will be documented in this file. +## [v1.6.5] - 2026-09-28 + +### 新增 + +- **Token 配置连接入口**:应用配置的 Token 区域新增两个入口: + - 「测试连接」:后端用 cloudflared 临时启动一次连接,最多等待 25 秒,确认 token 能注册到 Cloudflare 边缘;成功后显示隧道 ID,失败给出明确原因 + - 「获取 Token」:直达 Cloudflare Zero Trust 面板 +- 新增 `POST /api/cloudflare/settings/test` 接口;请求体 token 留空时使用已保存的默认 token + +### 变更 + +- 版本号提升到 1.6.5;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + ## [v1.6.4] - 2026-09-28 ### 变更 diff --git a/VERSION b/VERSION index 9edc58b..9f05f9f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.4 +1.6.5 diff --git a/docs/README.md b/docs/README.md index 4ee757d..9e64674 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.6.4) +> 最近更新:2026-09-28(v1.6.5) ## 方案与设计(本目录) diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index 7d16c91..5cc72d6 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.4 的验证流程: +示例页面用于验证升级结果。v1.6.5 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.4`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.5`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.6.4`; - - 页面显示验证标识 `v1.6.4-example-page`; + - 页面显示的版本为 `1.6.5`; + - 页面显示验证标识 `v1.6.5-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/api/cloudflare_handler.go b/internal/api/cloudflare_handler.go index fa10bd8..9a19fe9 100644 --- a/internal/api/cloudflare_handler.go +++ b/internal/api/cloudflare_handler.go @@ -189,6 +189,22 @@ func (h *CloudflareHandler) SaveAppSettings(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, settings) } +func (h *CloudflareHandler) TestToken(w http.ResponseWriter, r *http.Request) { + var request struct { + Token string `json:"token"` + } + 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) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message}) +} + func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) { var request struct { Mirror string `json:"mirror"` diff --git a/internal/api/router.go b/internal/api/router.go index 533e620..c0ae3fa 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -208,6 +208,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) { protect("POST /api/cloudflare/binary/download", cf.DownloadBinary) protect("GET /api/cloudflare/settings", cf.AppSettings) protect("PUT /api/cloudflare/settings", cf.SaveAppSettings) + protect("POST /api/cloudflare/settings/test", cf.TestToken) protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates) } diff --git a/internal/cloudflared/service.go b/internal/cloudflared/service.go index ccbad94..a9a3d3b 100644 --- a/internal/cloudflared/service.go +++ b/internal/cloudflared/service.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/fonu/fonu/internal/config" "github.com/fonu/fonu/internal/fsutil" @@ -276,6 +277,60 @@ func (s *Service) resolveToken(ctx context.Context, input string) (string, error return plain, nil } +// TestToken 用 cloudflared 临时启动一次连接,验证 token 能否注册到 Cloudflare 边缘。 +func (s *Service) TestToken(ctx context.Context, input string) (string, error) { + 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") +} + // attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。 func (s *Service) attachConfig(item *Tunnel) { data, err := os.ReadFile(item.ConfigPath) diff --git a/web/package-lock.json b/web/package-lock.json index c880043..0bf5179 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.6.4", + "version": "1.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.6.4", + "version": "1.6.5", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index 98e3c88..2e35bde 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.6.4", + "version": "1.6.5", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c2f39ea..f16f384 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -512,4 +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) => + request<{ ok: boolean; message: string }>('/api/cloudflare/settings/test', { + method: 'POST', + body: JSON.stringify({ token }), + }, 60_000), } diff --git a/web/src/views/CloudflareView.vue b/web/src/views/CloudflareView.vue index d1ed041..01d26cb 100644 --- a/web/src/views/CloudflareView.vue +++ b/web/src/views/CloudflareView.vue @@ -133,6 +133,13 @@ /> +
+ 测试连接 + 获取 Token +
+ + {{ tokenTestResult }} +

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

默认网络设置 @@ -175,6 +182,9 @@ const binaryLoading = ref(false) const settingsVisible = ref(false) const settingsLoading = ref(false) const settingsSaving = ref(false) +const tokenTesting = ref(false) +const tokenTestResult = ref('') +const tokenTestOk = ref(false) const appSettings = ref({ default_token_configured: false, default_token_masked: '', @@ -395,6 +405,7 @@ async function openSettings() { async function loadAppSettings() { settingsLoading.value = true + tokenTestResult.value = '' try { const result = await api.getCfSettings() appSettings.value = result @@ -406,6 +417,21 @@ async function loadAppSettings() { } } +async function testToken() { + tokenTesting.value = true + tokenTestResult.value = '' + try { + const result = await api.testCfToken(settingsForm.value.token) + tokenTestOk.value = true + tokenTestResult.value = result.message || '连接成功' + } catch (error) { + tokenTestOk.value = false + tokenTestResult.value = error instanceof Error ? error.message : '连接失败' + } finally { + tokenTesting.value = false + } +} + async function saveSettings() { settingsSaving.value = true try { diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index aec0d99..72d6d54 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.4。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.5。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.6.4。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.4。
  • -
  • 验证标识更新为 v1.6.4-example-page。
  • +
  • 页面版本提示更新为 v1.6.5。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.5。
  • +
  • 验证标识更新为 v1.6.5-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.4' -const verificationMarker = 'v1.6.4-example-page' +const verificationRound = 'v1.6.5' +const verificationMarker = 'v1.6.5-example-page' async function refreshVersion() { refreshing.value = true