feat(cloudflare): 应用配置支持程序安装与默认 Token 并发布 v1.6.3
This commit is contained in:
@@ -2,6 +2,25 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v1.6.2] - 2026-09-28
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Fonu 文档索引
|
# Fonu 文档索引
|
||||||
|
|
||||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||||
> 最近更新:2026-09-28(v1.6.2)
|
> 最近更新:2026-09-28(v1.6.3)
|
||||||
|
|
||||||
## 方案与设计(本目录)
|
## 方案与设计(本目录)
|
||||||
|
|
||||||
|
|||||||
@@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
|
|||||||
|
|
||||||
## 10. 在线升级验证
|
## 10. 在线升级验证
|
||||||
|
|
||||||
示例页面用于验证升级结果。v1.6.2 的验证流程:
|
示例页面用于验证升级结果。v1.6.3 的验证流程:
|
||||||
|
|
||||||
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
|
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
|
||||||
2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.2`。
|
2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.3`。
|
||||||
3. 在侧栏看到「一键升级」后点击并确认。
|
3. 在侧栏看到「一键升级」后点击并确认。
|
||||||
4. 等待升级弹窗显示成功,页面自动刷新。
|
4. 等待升级弹窗显示成功,页面自动刷新。
|
||||||
5. 打开侧栏「示例页面」,确认:
|
5. 打开侧栏「示例页面」,确认:
|
||||||
- 页面路由为 `/example`;
|
- 页面路由为 `/example`;
|
||||||
- 页面显示的版本为 `1.6.2`;
|
- 页面显示的版本为 `1.6.3`;
|
||||||
- 页面显示验证标识 `v1.6.2-example-page`;
|
- 页面显示验证标识 `v1.6.3-example-page`;
|
||||||
- 页面显示独立的「本次升级验证」区块;
|
- 页面显示独立的「本次升级验证」区块;
|
||||||
- 「升级验证」显示为「通过」。
|
- 「升级验证」显示为「通过」。
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) {
|
||||||
var request struct {
|
var request struct {
|
||||||
Mirror string `json:"mirror"`
|
Mirror string `json:"mirror"`
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
|||||||
protect("GET /api/cloudflare/tunnels/{id}/config", cf.Config)
|
protect("GET /api/cloudflare/tunnels/{id}/config", cf.Config)
|
||||||
protect("GET /api/cloudflare/binary", cf.BinaryInfo)
|
protect("GET /api/cloudflare/binary", cf.BinaryInfo)
|
||||||
protect("POST /api/cloudflare/binary/download", cf.DownloadBinary)
|
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)
|
protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,21 @@ type CreateInput struct {
|
|||||||
AutoStart bool `json:"auto_start"`
|
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 是隧道运行状态与指标快照。
|
// RuntimeStatus 是隧道运行状态与指标快照。
|
||||||
type RuntimeStatus struct {
|
type RuntimeStatus struct {
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
|
|||||||
@@ -72,7 +72,12 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
|
|||||||
if err := validateInput(in); err != nil {
|
if err := validateInput(in); err != nil {
|
||||||
return Tunnel{}, err
|
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 {
|
if err != nil {
|
||||||
return Tunnel{}, err
|
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 {
|
if err := fsutil.WriteFileAtomic(configPath, []byte(configContent), 0o640); err != nil {
|
||||||
return Tunnel{}, err
|
return Tunnel{}, err
|
||||||
}
|
}
|
||||||
encrypted, err := s.secretBox.Encrypt(in.Token)
|
encrypted, err := s.secretBox.Encrypt(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Tunnel{}, err
|
return Tunnel{}, err
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,7 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel,
|
|||||||
return Tunnel{}, err
|
return Tunnel{}, err
|
||||||
}
|
}
|
||||||
_ = s.manager.Stop(id)
|
_ = s.manager.Stop(id)
|
||||||
|
in.Network = s.applyDefaultNetwork(ctx, in.Network)
|
||||||
accountTag, tunnelID := current.AccountTag, current.TunnelID
|
accountTag, tunnelID := current.AccountTag, current.TunnelID
|
||||||
encrypted := current.CredentialsEnc
|
encrypted := current.CredentialsEnc
|
||||||
if token := strings.TrimSpace(in.Token); token != "" {
|
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
|
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,供编辑弹窗使用。
|
// attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
|
||||||
func (s *Service) attachConfig(item *Tunnel) {
|
func (s *Service) attachConfig(item *Tunnel) {
|
||||||
data, err := os.ReadFile(item.ConfigPath)
|
data, err := os.ReadFile(item.ConfigPath)
|
||||||
@@ -298,8 +383,5 @@ func validateInput(in CreateInput) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("暂不支持该模式,当前仅支持 token-local / token-remote")
|
return fmt.Errorf("暂不支持该模式,当前仅支持 token-local / token-remote")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(in.Token) == "" {
|
|
||||||
return fmt.Errorf("token 不能为空")
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,40 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri
|
|||||||
return err
|
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) {
|
func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) {
|
||||||
var item Tunnel
|
var item Tunnel
|
||||||
var networkJSON, created, updated string
|
var networkJSON, created, updated string
|
||||||
|
|||||||
@@ -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);
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.6.2",
|
"version": "1.6.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.6.2",
|
"version": "1.6.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.11.1",
|
"@codemirror/commands": "^6.11.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.6.2",
|
"version": "1.6.3",
|
||||||
"packageManager": "npm@10.9.4",
|
"packageManager": "npm@10.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import type {
|
|||||||
CfRuntimeStatus,
|
CfRuntimeStatus,
|
||||||
CfTunnel,
|
CfTunnel,
|
||||||
CfTunnelPayload,
|
CfTunnelPayload,
|
||||||
|
CfAppSettings,
|
||||||
|
CfAppSettingsInput,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export function asList<T>(value: T[] | null | undefined): T[] {
|
export function asList<T>(value: T[] | null | undefined): T[] {
|
||||||
@@ -507,4 +509,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
getCfIngressCandidates: () =>
|
getCfIngressCandidates: () =>
|
||||||
request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'),
|
request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'),
|
||||||
|
getCfSettings: () => request<CfAppSettings>('/api/cloudflare/settings'),
|
||||||
|
saveCfSettings: (payload: CfAppSettingsInput) =>
|
||||||
|
request<CfAppSettings>('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1098,3 +1098,16 @@ export interface CfTunnelPayload {
|
|||||||
network: CfNetworkSettings
|
network: CfNetworkSettings
|
||||||
auto_start: boolean
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
|
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<n-button :loading="binaryLoading" @click="openBinary">cloudflared 二进制</n-button>
|
<n-button :loading="settingsLoading" @click="openSettings">应用配置</n-button>
|
||||||
<n-button type="primary" @click="openCreate">新建隧道</n-button>
|
<n-button type="primary" @click="openCreate">新建隧道</n-button>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -47,7 +47,12 @@
|
|||||||
<n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item>
|
<n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item>
|
||||||
<n-form-item label="模式"><n-select v-model:value="form.mode" :options="modeOptions" /></n-form-item>
|
<n-form-item label="模式"><n-select v-model:value="form.mode" :options="modeOptions" /></n-form-item>
|
||||||
<n-form-item :label="editing ? 'Token(留空保留原值)' : 'Token'">
|
<n-form-item :label="editing ? 'Token(留空保留原值)' : 'Token'">
|
||||||
<n-input v-model:value="form.token" type="password" show-password-on="click" placeholder="Cloudflare 面板生成的隧道 token" />
|
<n-input
|
||||||
|
v-model:value="form.token"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
:placeholder="appSettings.default_token_configured ? '留空使用应用配置中的默认 token' : 'Cloudflare 面板生成的隧道 token'"
|
||||||
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<n-divider title-placement="left">暴露规则</n-divider>
|
<n-divider title-placement="left">暴露规则</n-divider>
|
||||||
@@ -108,23 +113,48 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
<n-modal v-model:show="binaryVisible" preset="card" :style="{ width: 'min(560px, 94vw)' }" title="cloudflared 二进制">
|
<n-modal v-model:show="settingsVisible" preset="card" :style="{ width: 'min(640px, 96vw)' }" title="Cloudflare 应用配置">
|
||||||
<div class="cf-modal__body">
|
<div class="cf-modal__body">
|
||||||
|
<n-divider title-placement="left">程序安装</n-divider>
|
||||||
<div class="cf-binary">
|
<div class="cf-binary">
|
||||||
<div><label>当前版本</label><strong class="mono">{{ binaryVersion || '未安装' }}</strong></div>
|
<div><label>当前版本</label><strong class="mono">{{ binaryVersion || '未安装' }}</strong></div>
|
||||||
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
|
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<n-form label-placement="top">
|
<n-form label-placement="top">
|
||||||
<n-form-item label="下载镜像">
|
<n-form-item label="下载镜像">
|
||||||
<n-select v-model:value="selectedMirror" :options="mirrorOptions" />
|
<n-select v-model:value="settingsForm.mirror" :options="mirrorOptions" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
|
<div class="cf-code-toolbar">
|
||||||
|
<n-button size="small" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
|
||||||
|
</div>
|
||||||
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
|
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
|
||||||
|
|
||||||
|
<n-divider title-placement="left">Token 配置</n-divider>
|
||||||
|
<n-form label-placement="top">
|
||||||
|
<n-form-item label="默认 Token">
|
||||||
|
<n-input
|
||||||
|
v-model:value="settingsForm.token"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
:placeholder="appSettings.default_token_configured ? `已配置 ${appSettings.default_token_masked},留空保留` : '粘贴 Cloudflare 隧道 token'"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<p class="cf-hint">保存后,新建隧道时可以留空 token,直接使用这里的默认值。</p>
|
||||||
|
|
||||||
|
<n-divider title-placement="left">默认网络设置</n-divider>
|
||||||
|
<n-form label-placement="top">
|
||||||
|
<n-form-item label="传输协议"><n-select v-model:value="settingsForm.network.transport_protocol" :options="protocolOptions" /></n-form-item>
|
||||||
|
<n-form-item label="边缘地址族"><n-select v-model:value="settingsForm.network.edge_ip_version" :options="edgeOptions" /></n-form-item>
|
||||||
|
<n-form-item label="HA 连接数"><n-input-number v-model:value="settingsForm.network.ha_connections" :min="1" :max="4" /></n-form-item>
|
||||||
|
<n-form-item label="代理模式"><n-select v-model:value="settingsForm.network.proxy_mode" :options="proxyOptions" /></n-form-item>
|
||||||
|
</n-form>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="cf-modal__footer">
|
<div class="cf-modal__footer">
|
||||||
<n-button @click="binaryVisible = false">取消</n-button>
|
<n-button @click="settingsVisible = false">取消</n-button>
|
||||||
<n-button type="primary" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
|
<n-button type="primary" :loading="settingsSaving" @click="saveSettings">保存配置</n-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
@@ -134,7 +164,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
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 { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
|
||||||
import { api, asList } from '../api/client'
|
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 FonuCard from '../components/FonuCard.vue'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
import StatusBadge from '../components/StatusBadge.vue'
|
import StatusBadge from '../components/StatusBadge.vue'
|
||||||
@@ -150,9 +180,17 @@ const candidates = ref<string[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const binaryLoading = 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<CfAppSettings>({
|
||||||
|
default_token_configured: false,
|
||||||
|
default_token_masked: '',
|
||||||
|
mirror: 'official',
|
||||||
|
network: emptyNetwork(),
|
||||||
|
})
|
||||||
|
const settingsForm = ref<CfAppSettingsInput>({ token: '', mirror: 'official', network: emptyNetwork() })
|
||||||
const editorVisible = ref(false)
|
const editorVisible = ref(false)
|
||||||
const binaryVisible = ref(false)
|
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const editing = ref<CfTunnel | null>(null)
|
const editing = ref<CfTunnel | null>(null)
|
||||||
const detailTunnel = ref<CfTunnel | null>(null)
|
const detailTunnel = ref<CfTunnel | null>(null)
|
||||||
@@ -197,11 +235,15 @@ function emptyForm(): CfTunnelPayload {
|
|||||||
token: '',
|
token: '',
|
||||||
hostnames: [],
|
hostnames: [],
|
||||||
origin_service: 'http://127.0.0.1:80',
|
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,
|
auto_start: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyNetwork() {
|
||||||
|
return { transport_protocol: 'http2', edge_ip_version: '4', ha_connections: 2, proxy_mode: 'system' }
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -256,8 +298,8 @@ async function saveTunnel() {
|
|||||||
message.warning('请填写隧道名称')
|
message.warning('请填写隧道名称')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!editing.value && !(form.value.token || '').trim()) {
|
if (!editing.value && !(form.value.token || '').trim() && !appSettings.value.default_token_configured) {
|
||||||
message.warning('请填写 Cloudflare 隧道 token')
|
message.warning('请填写 token,或先在「应用配置」中保存默认 token')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.value.mode === 'token-local' && !form.value.hostnames.length) {
|
if (form.value.mode === 'token-local' && !form.value.hostnames.length) {
|
||||||
@@ -349,14 +391,42 @@ async function copyConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBinary() {
|
async function openSettings() {
|
||||||
binaryVisible.value = true
|
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() {
|
async function downloadBinary() {
|
||||||
binaryLoading.value = true
|
binaryLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await api.downloadCfBinary(selectedMirror.value)
|
const result = await api.downloadCfBinary(settingsForm.value.mirror || 'official')
|
||||||
binaryVersion.value = result.version || ''
|
binaryVersion.value = result.version || ''
|
||||||
message.success('cloudflared 已下载并安装')
|
message.success('cloudflared 已下载并安装')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -392,6 +462,7 @@ function statusKind(status: string): StatusKind {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load()
|
void load()
|
||||||
void loadBinary()
|
void loadBinary()
|
||||||
|
void loadAppSettings()
|
||||||
void loadCandidates()
|
void loadCandidates()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<n-alert type="success" :bordered="false" class="example-alert" title="v1.6.2 示例页面验证标识已生效">
|
<n-alert type="success" :bordered="false" class="example-alert" title="v1.6.3 示例页面验证标识已生效">
|
||||||
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
|
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
|
||||||
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
|
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
|
||||||
</n-alert>
|
</n-alert>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<strong class="example-proof__value">{{ verificationRound }}</strong>
|
<strong class="example-proof__value">{{ verificationRound }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="example-proof__hint">
|
<div class="example-proof__hint">
|
||||||
看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.2。
|
看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.3。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,16 +33,16 @@
|
|||||||
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
|
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
|
||||||
<template #icon><n-icon :component="FlashOutline" /></template>
|
<template #icon><n-icon :component="FlashOutline" /></template>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
<StatCard label="页面状态" value="已更新" sub="v1.6.2 前端资源已加载" tone="teal">
|
<StatCard label="页面状态" value="已更新" sub="v1.6.3 前端资源已加载" tone="teal">
|
||||||
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
|
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
|
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
|
||||||
<ul class="example-changes">
|
<ul class="example-changes">
|
||||||
<li>页面版本提示更新为 v1.6.2。</li>
|
<li>页面版本提示更新为 v1.6.3。</li>
|
||||||
<li>「本次升级验证」区块和页面状态同步更新为 v1.6.2。</li>
|
<li>「本次升级验证」区块和页面状态同步更新为 v1.6.3。</li>
|
||||||
<li>验证标识更新为 <span class="mono">v1.6.2-example-page</span>。</li>
|
<li>验证标识更新为 <span class="mono">v1.6.3-example-page</span>。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ const message = useMessage()
|
|||||||
const version = ref(__APP_VERSION__)
|
const version = ref(__APP_VERSION__)
|
||||||
const checkedAt = ref('')
|
const checkedAt = ref('')
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const verificationRound = 'v1.6.2'
|
const verificationRound = 'v1.6.3'
|
||||||
const verificationMarker = 'v1.6.2-example-page'
|
const verificationMarker = 'v1.6.3-example-page'
|
||||||
|
|
||||||
async function refreshVersion() {
|
async function refreshVersion() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user