diff --git a/CHANGELOG.md b/CHANGELOG.md index dbdf0da..ff775fd 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.3] - 2026-09-28 + +### 新增 + +- **Cloudflare 应用配置**:「cloudflared 二进制」弹窗升级为「应用配置」,包含三块: + - 程序安装:当前版本、下载镜像、下载并安装 + - Token 配置:保存模块级默认 token,掩码回显;新建隧道时可以留空,直接使用默认 token + - 默认网络设置:传输协议、边缘地址族、HA 连接数、代理模式,作为新建隧道的默认值 +- 新增 `cf_settings` 表(迁移 `031`)与 `GET / PUT /api/cloudflare/settings` 接口 + +### 变更 + +- 版本号提升到 1.6.3;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- 新增应用配置与默认 token 的端到端单元测试 +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + ## [v1.6.2] - 2026-09-28 ### 修复 diff --git a/VERSION b/VERSION index fdd3be6..266146b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.2 +1.6.3 diff --git a/docs/README.md b/docs/README.md index 57771f0..eb936e4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.6.2) +> 最近更新:2026-09-28(v1.6.3) ## 方案与设计(本目录) diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index 8096a69..e166988 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.2 的验证流程: +示例页面用于验证升级结果。v1.6.3 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.2`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.3`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.6.2`; - - 页面显示验证标识 `v1.6.2-example-page`; + - 页面显示的版本为 `1.6.3`; + - 页面显示验证标识 `v1.6.3-example-page`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/api/cloudflare_handler.go b/internal/api/cloudflare_handler.go index ba8ef59..fa10bd8 100644 --- a/internal/api/cloudflare_handler.go +++ b/internal/api/cloudflare_handler.go @@ -165,6 +165,30 @@ func (h *CloudflareHandler) BinaryInfo(w http.ResponseWriter, r *http.Request) { }) } +func (h *CloudflareHandler) AppSettings(w http.ResponseWriter, r *http.Request) { + settings, err := h.svc.AppSettings(r.Context()) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, settings) +} + +func (h *CloudflareHandler) SaveAppSettings(w http.ResponseWriter, r *http.Request) { + var input cloudflared.AppSettingsInput + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil { + writeError(r, w, http.StatusBadRequest, "请求格式无效") + return + } + input.Network = normalizeCloudflareNetwork(input.Network) + settings, err := h.svc.SaveAppSettings(r.Context(), input) + if err != nil { + writeError(r, w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, settings) +} + 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 1c58a32..533e620 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -206,6 +206,8 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) { protect("GET /api/cloudflare/tunnels/{id}/config", cf.Config) protect("GET /api/cloudflare/binary", cf.BinaryInfo) protect("POST /api/cloudflare/binary/download", cf.DownloadBinary) + protect("GET /api/cloudflare/settings", cf.AppSettings) + protect("PUT /api/cloudflare/settings", cf.SaveAppSettings) protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates) } diff --git a/internal/cloudflared/model.go b/internal/cloudflared/model.go index 02e921a..69a333e 100644 --- a/internal/cloudflared/model.go +++ b/internal/cloudflared/model.go @@ -70,6 +70,21 @@ type CreateInput struct { AutoStart bool `json:"auto_start"` } +// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。 +type AppSettings struct { + DefaultTokenConfigured bool `json:"default_token_configured"` + DefaultTokenMasked string `json:"default_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"` +} + // RuntimeStatus 是隧道运行状态与指标快照。 type RuntimeStatus struct { Running bool `json:"running"` diff --git a/internal/cloudflared/service.go b/internal/cloudflared/service.go index b9600ee..ccbad94 100644 --- a/internal/cloudflared/service.go +++ b/internal/cloudflared/service.go @@ -72,7 +72,12 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) { if err := validateInput(in); err != nil { return Tunnel{}, err } - accountTag, tunnelID, tunnelSecret, err := DecodeToken(in.Token) + in.Network = s.applyDefaultNetwork(ctx, in.Network) + token, err := s.resolveToken(ctx, in.Token) + if err != nil { + return Tunnel{}, err + } + accountTag, tunnelID, tunnelSecret, err := DecodeToken(token) if err != nil { return Tunnel{}, err } @@ -95,7 +100,7 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) { if err := fsutil.WriteFileAtomic(configPath, []byte(configContent), 0o640); err != nil { return Tunnel{}, err } - encrypted, err := s.secretBox.Encrypt(in.Token) + encrypted, err := s.secretBox.Encrypt(token) if err != nil { return Tunnel{}, err } @@ -109,6 +114,7 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, return Tunnel{}, err } _ = 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 != "" { @@ -191,6 +197,85 @@ func (s *Service) RuntimeStatus(ctx context.Context, id int64) (RuntimeStatus, e return s.manager.Status(id), nil } +// AppSettings 返回模块级配置;默认 token 只回显掩码。 +func (s *Service) AppSettings(ctx context.Context) (AppSettings, error) { + row, err := s.store.GetSettingsRaw(ctx) + if err != nil { + return AppSettings{}, err + } + out := AppSettings{Mirror: row.Mirror, Network: row.Network} + if row.TokenEnc != "" { + out.DefaultTokenConfigured = true + if plain, err := s.secretBox.Decrypt(row.TokenEnc); err == nil { + out.DefaultTokenMasked = MaskSecret(plain) + } + } + return out, nil +} + +// SaveAppSettings 保存默认 token、下载镜像与默认网络设置;Token 留空表示保留原值。 +func (s *Service) SaveAppSettings(ctx context.Context, in AppSettingsInput) (AppSettings, error) { + row, err := s.store.GetSettingsRaw(ctx) + 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 + } + } + mirror := strings.TrimSpace(in.Mirror) + if mirror == "" { + mirror = row.Mirror + } + if mirror == "" { + mirror = "official" + } + network := in.Network + if strings.TrimSpace(network.TransportProtocol) == "" { + network = row.Network + } + if err := s.store.SaveSettings(ctx, tokenEnc, mirror, network); err != nil { + return AppSettings{}, err + } + return s.AppSettings(ctx) +} + +func (s *Service) applyDefaultNetwork(ctx context.Context, network NetworkSettings) NetworkSettings { + if strings.TrimSpace(network.TransportProtocol) != "" { + return network + } + row, err := s.store.GetSettingsRaw(ctx) + if err != nil { + return DefaultNetworkSettings() + } + return row.Network +} + +func (s *Service) resolveToken(ctx context.Context, input string) (string, error) { + token := strings.TrimSpace(input) + if token != "" { + return token, nil + } + row, err := s.store.GetSettingsRaw(ctx) + if err != nil { + return "", err + } + if row.TokenEnc == "" { + return "", fmt.Errorf("请填写 token,或先在「应用配置」中保存默认 token") + } + plain, err := s.secretBox.Decrypt(row.TokenEnc) + if err != nil { + return "", fmt.Errorf("默认 token 解密失败: %w", err) + } + return plain, nil +} + // attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。 func (s *Service) attachConfig(item *Tunnel) { data, err := os.ReadFile(item.ConfigPath) @@ -298,8 +383,5 @@ func validateInput(in CreateInput) error { default: return fmt.Errorf("暂不支持该模式,当前仅支持 token-local / token-remote") } - if strings.TrimSpace(in.Token) == "" { - return fmt.Errorf("token 不能为空") - } return nil } diff --git a/internal/cloudflared/settings_test.go b/internal/cloudflared/settings_test.go new file mode 100644 index 0000000..9f80c60 --- /dev/null +++ b/internal/cloudflared/settings_test.go @@ -0,0 +1,52 @@ +package cloudflared + +import ( + "context" + "encoding/base64" + "io" + "log/slog" + "path/filepath" + "testing" + + "github.com/fonu/fonu/internal/config" + "github.com/fonu/fonu/internal/db" + "github.com/fonu/fonu/internal/secret" +) + +func TestAppSettingsDefaultToken(t *testing.T) { + dir := t.TempDir() + conn, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil { + t.Fatal(err) + } + box, err := secret.NewBox("cloudflared-settings-test") + if err != nil { + 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(), + }) + if err != nil { + t.Fatal(err) + } + if !settings.DefaultTokenConfigured || settings.DefaultTokenMasked == "" { + t.Fatalf("默认 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) + } +} diff --git a/internal/cloudflared/store.go b/internal/cloudflared/store.go index 5b38ae6..a12a6e7 100644 --- a/internal/cloudflared/store.go +++ b/internal/cloudflared/store.go @@ -95,6 +95,40 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri return err } +// settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。 +type settingsRow struct { + TokenEnc string + Mirror string + Network NetworkSettings +} + +func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) { + var tokenEnc, mirror, networkJSON string + err := s.db.QueryRowContext(ctx, ` + SELECT COALESCE(default_token_enc, ''), COALESCE(mirror, 'official'), COALESCE(network_json, '{}') + FROM cf_settings WHERE id = 1 + `).Scan(&tokenEnc, &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()} + _ = json.Unmarshal([]byte(networkJSON), &row.Network) + return row, nil +} + +func (s *Store) SaveSettings(ctx context.Context, tokenEnc, 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') + WHERE id = 1 + `, tokenEnc, mirror, string(networkJSON)) + return err +} + func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) { var item Tunnel var networkJSON, created, updated string diff --git a/migrations/031_cf_settings.sql b/migrations/031_cf_settings.sql new file mode 100644 index 0000000..f3f34e4 --- /dev/null +++ b/migrations/031_cf_settings.sql @@ -0,0 +1,9 @@ +-- Cloudflare 隧道模块的应用级配置:默认 Token、下载镜像与默认网络设置。 +CREATE TABLE IF NOT EXISTS cf_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + default_token_enc TEXT NOT NULL DEFAULT '', + mirror TEXT NOT NULL DEFAULT 'official', + network_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +INSERT OR IGNORE INTO cf_settings(id) VALUES (1); diff --git a/web/package-lock.json b/web/package-lock.json index 7154bf6..e08070e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.6.2", + "version": "1.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.6.2", + "version": "1.6.3", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index 128faa9..402b97a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.6.2", + "version": "1.6.3", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0f3b639..c2f39ea 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -41,6 +41,8 @@ import type { CfRuntimeStatus, CfTunnel, CfTunnelPayload, + CfAppSettings, + CfAppSettingsInput, } from './types' export function asList(value: T[] | null | undefined): T[] { @@ -507,4 +509,7 @@ export const api = { }), getCfIngressCandidates: () => request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'), + getCfSettings: () => request('/api/cloudflare/settings'), + saveCfSettings: (payload: CfAppSettingsInput) => + request('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }), } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 444f3d4..d87b179 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1098,3 +1098,16 @@ export interface CfTunnelPayload { network: CfNetworkSettings auto_start: boolean } + +export interface CfAppSettings { + default_token_configured: boolean + default_token_masked: string + mirror: string + network: CfNetworkSettings +} + +export interface CfAppSettingsInput { + token: string + mirror: string + network: CfNetworkSettings +} diff --git a/web/src/views/CloudflareView.vue b/web/src/views/CloudflareView.vue index 9f78f73..b650a8c 100644 --- a/web/src/views/CloudflareView.vue +++ b/web/src/views/CloudflareView.vue @@ -1,7 +1,7 @@ - +
+ 程序安装
{{ binaryVersion || '未安装' }}
cloudflared
- + +
+ 下载并安装 +

下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。

+ + Token 配置 + + + + + +

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

+ + 默认网络设置 + + + + + +
@@ -134,7 +164,7 @@ import { computed, onMounted, ref } from 'vue' import { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui' import { api, asList } from '../api/client' -import type { CfMirror, CfRuntimeStatus, CfTunnel, CfTunnelPayload } from '../api/types' +import type { CfAppSettings, CfAppSettingsInput, CfMirror, CfRuntimeStatus, CfTunnel, CfTunnelPayload } from '../api/types' import FonuCard from '../components/FonuCard.vue' import PageHeader from '../components/PageHeader.vue' import StatusBadge from '../components/StatusBadge.vue' @@ -150,9 +180,17 @@ const candidates = ref([]) const loading = ref(false) const saving = ref(false) const binaryLoading = ref(false) -const selectedMirror = ref('official') +const settingsVisible = ref(false) +const settingsLoading = ref(false) +const settingsSaving = ref(false) +const appSettings = ref({ + default_token_configured: false, + default_token_masked: '', + mirror: 'official', + network: emptyNetwork(), +}) +const settingsForm = ref({ token: '', mirror: 'official', network: emptyNetwork() }) const editorVisible = ref(false) -const binaryVisible = ref(false) const detailVisible = ref(false) const editing = ref(null) const detailTunnel = ref(null) @@ -197,11 +235,15 @@ function emptyForm(): CfTunnelPayload { token: '', hostnames: [], origin_service: 'http://127.0.0.1:80', - network: { transport_protocol: 'http2', edge_ip_version: '4', ha_connections: 2, proxy_mode: 'system' }, + network: emptyNetwork(), auto_start: false, } } +function emptyNetwork() { + return { transport_protocol: 'http2', edge_ip_version: '4', ha_connections: 2, proxy_mode: 'system' } +} + async function load() { loading.value = true try { @@ -256,8 +298,8 @@ async function saveTunnel() { message.warning('请填写隧道名称') return } - if (!editing.value && !(form.value.token || '').trim()) { - message.warning('请填写 Cloudflare 隧道 token') + if (!editing.value && !(form.value.token || '').trim() && !appSettings.value.default_token_configured) { + message.warning('请填写 token,或先在「应用配置」中保存默认 token') return } if (form.value.mode === 'token-local' && !form.value.hostnames.length) { @@ -349,14 +391,42 @@ async function copyConfig() { } } -function openBinary() { - binaryVisible.value = true +async function openSettings() { + settingsVisible.value = true + await loadAppSettings() +} + +async function loadAppSettings() { + settingsLoading.value = true + try { + const result = await api.getCfSettings() + appSettings.value = result + settingsForm.value = { token: '', mirror: result.mirror || 'official', network: { ...result.network } } + } catch (error) { + message.error(error instanceof Error ? error.message : '读取应用配置失败') + } finally { + settingsLoading.value = false + } +} + +async function saveSettings() { + settingsSaving.value = true + try { + const result = await api.saveCfSettings(settingsForm.value) + appSettings.value = result + settingsForm.value = { token: '', mirror: result.mirror, network: { ...result.network } } + message.success('应用配置已保存') + } catch (error) { + message.error(error instanceof Error ? error.message : '保存应用配置失败') + } finally { + settingsSaving.value = false + } } async function downloadBinary() { binaryLoading.value = true try { - const result = await api.downloadCfBinary(selectedMirror.value) + const result = await api.downloadCfBinary(settingsForm.value.mirror || 'official') binaryVersion.value = result.version || '' message.success('cloudflared 已下载并安装') } catch (error) { @@ -392,6 +462,7 @@ function statusKind(status: string): StatusKind { onMounted(() => { void load() void loadBinary() + void loadAppSettings() void loadCandidates() }) diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index be1bad4..171596a 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.2。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.3。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.6.2。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.2。
  • -
  • 验证标识更新为 v1.6.2-example-page。
  • +
  • 页面版本提示更新为 v1.6.3。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.3。
  • +
  • 验证标识更新为 v1.6.3-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.2' -const verificationMarker = 'v1.6.2-example-page' +const verificationRound = 'v1.6.3' +const verificationMarker = 'v1.6.3-example-page' async function refreshVersion() { refreshing.value = true