feat(cloudflare): 改用 Account ID + API Token 创建管理隧道并发布 v1.6.8
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.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
|
## [v1.6.7] - 2026-09-28
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Fonu 文档索引
|
# Fonu 文档索引
|
||||||
|
|
||||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||||
> 最近更新:2026-09-28(v1.6.7)
|
> 最近更新:2026-09-28(v1.6.8)
|
||||||
|
|
||||||
## 方案与设计(本目录)
|
## 方案与设计(本目录)
|
||||||
|
|
||||||
|
|||||||
@@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
|
|||||||
|
|
||||||
## 10. 在线升级验证
|
## 10. 在线升级验证
|
||||||
|
|
||||||
示例页面用于验证升级结果。v1.6.7 的验证流程:
|
示例页面用于验证升级结果。v1.6.8 的验证流程:
|
||||||
|
|
||||||
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
|
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
|
||||||
2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.7`。
|
2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.8`。
|
||||||
3. 在侧栏看到「一键升级」后点击并确认。
|
3. 在侧栏看到「一键升级」后点击并确认。
|
||||||
4. 等待升级弹窗显示成功,页面自动刷新。
|
4. 等待升级弹窗显示成功,页面自动刷新。
|
||||||
5. 打开侧栏「示例页面」,确认:
|
5. 打开侧栏「示例页面」,确认:
|
||||||
- 页面路由为 `/example`;
|
- 页面路由为 `/example`;
|
||||||
- 页面显示的版本为 `1.6.7`;
|
- 页面显示的版本为 `1.6.8`;
|
||||||
- 页面显示验证标识 `v1.6.7-example-page`;
|
- 页面显示验证标识 `v1.6.8-example-page`;
|
||||||
- 页面显示独立的「本次升级验证」区块;
|
- 页面显示独立的「本次升级验证」区块;
|
||||||
- 「升级验证」显示为「通过」。
|
- 「升级验证」显示为「通过」。
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ type CreateInput struct {
|
|||||||
|
|
||||||
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。
|
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。
|
||||||
type AppSettings struct {
|
type AppSettings struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
DefaultTokenConfigured bool `json:"default_token_configured"`
|
DefaultTokenConfigured bool `json:"default_token_configured"`
|
||||||
DefaultTokenMasked string `json:"default_token_masked"`
|
DefaultTokenMasked string `json:"default_token_masked"`
|
||||||
APITokenConfigured bool `json:"api_token_configured"`
|
APITokenConfigured bool `json:"api_token_configured"`
|
||||||
@@ -82,6 +83,7 @@ type AppSettings struct {
|
|||||||
|
|
||||||
// AppSettingsInput 是保存应用配置的入参;Token 留空表示保留原值。
|
// AppSettingsInput 是保存应用配置的入参;Token 留空表示保留原值。
|
||||||
type AppSettingsInput struct {
|
type AppSettingsInput struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
APIToken string `json:"api_token"`
|
APIToken string `json:"api_token"`
|
||||||
Mirror string `json:"mirror"`
|
Mirror string `json:"mirror"`
|
||||||
|
|||||||
+68
-144
@@ -2,15 +2,11 @@ package cloudflared
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/config"
|
"github.com/fonu/fonu/internal/config"
|
||||||
"github.com/fonu/fonu/internal/fsutil"
|
"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,并写入数据库。
|
// Create 解析 token、生成 creds.json 与 config.yml,并写入数据库。
|
||||||
func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
|
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 {
|
if err := validateInput(in); err != nil {
|
||||||
return Tunnel{}, err
|
return Tunnel{}, err
|
||||||
}
|
}
|
||||||
in.Network = s.applyDefaultNetwork(ctx, in.Network)
|
api, err := s.cloudflareAPI(ctx)
|
||||||
token, err := s.resolveToken(ctx, in.Token)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Tunnel{}, err
|
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 {
|
if err != nil {
|
||||||
return Tunnel{}, err
|
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)
|
dir := s.tunnelDir(in.Name)
|
||||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
_ = api.DeleteTunnel(ctx, tunnelID)
|
||||||
return Tunnel{}, err
|
return Tunnel{}, err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(dir, "config.yml")
|
configPath := filepath.Join(dir, "config.yml")
|
||||||
@@ -97,21 +102,29 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
|
|||||||
origin = defaultOriginService
|
origin = defaultOriginService
|
||||||
}
|
}
|
||||||
ingress := BuildIngress(in.Hostnames, origin)
|
ingress := BuildIngress(in.Hostnames, origin)
|
||||||
configContent := BuildConfig(tunnelID, credsPath, in.Network, ingress)
|
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
|
||||||
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, tunnelID, tunnelSecret)), 0o600); err != nil {
|
_ = api.DeleteTunnel(ctx, tunnelID)
|
||||||
return Tunnel{}, err
|
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
|
return Tunnel{}, err
|
||||||
}
|
}
|
||||||
encrypted, err := s.secretBox.Encrypt(token)
|
encrypted, err := s.secretBox.Encrypt(tunnelToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = api.DeleteTunnel(ctx, tunnelID)
|
||||||
return Tunnel{}, err
|
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) {
|
func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, error) {
|
||||||
current, err := s.store.Get(ctx, id)
|
current, err := s.store.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -119,30 +132,20 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel,
|
|||||||
}
|
}
|
||||||
_ = s.manager.Stop(id)
|
_ = s.manager.Stop(id)
|
||||||
in.Network = s.applyDefaultNetwork(ctx, in.Network)
|
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)
|
origin := strings.TrimSpace(in.OriginService)
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
origin = defaultOriginService
|
origin = defaultOriginService
|
||||||
}
|
}
|
||||||
configContent := BuildConfig(tunnelID, current.CredsPath, in.Network, BuildIngress(in.Hostnames, origin))
|
ingress := BuildIngress(in.Hostnames, origin)
|
||||||
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(configContent), 0o640); err != nil {
|
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(BuildConfig(current.TunnelID, current.CredsPath, in.Network, ingress)), 0o640); err != nil {
|
||||||
return Tunnel{}, err
|
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)
|
current.ConfigPath, current.CredsPath, current.LogPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +155,11 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_ = s.manager.Stop(id)
|
_ = 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) {
|
if dir := filepath.Dir(current.ConfigPath); dir != "" && strings.HasPrefix(dir, s.cfg.DataDir) {
|
||||||
_ = os.RemoveAll(dir)
|
_ = os.RemoveAll(dir)
|
||||||
}
|
}
|
||||||
@@ -207,7 +215,7 @@ func (s *Service) AppSettings(ctx context.Context) (AppSettings, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AppSettings{}, err
|
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 != "" {
|
if row.TokenEnc != "" {
|
||||||
out.DefaultTokenConfigured = true
|
out.DefaultTokenConfigured = true
|
||||||
if plain, err := s.secretBox.Decrypt(row.TokenEnc); err == nil {
|
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 {
|
if err != nil {
|
||||||
return AppSettings{}, err
|
return AppSettings{}, err
|
||||||
}
|
}
|
||||||
|
accountID := strings.TrimSpace(in.AccountID)
|
||||||
|
if accountID == "" {
|
||||||
|
accountID = row.AccountID
|
||||||
|
}
|
||||||
tokenEnc := row.TokenEnc
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apiTokenEnc := row.APITokenEnc
|
apiTokenEnc := row.APITokenEnc
|
||||||
if apiToken := strings.TrimSpace(in.APIToken); apiToken != "" {
|
if apiToken := strings.TrimSpace(in.APIToken); apiToken != "" {
|
||||||
apiTokenEnc, err = s.secretBox.Encrypt(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) == "" {
|
if strings.TrimSpace(network.TransportProtocol) == "" {
|
||||||
network = row.Network
|
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 AppSettings{}, err
|
||||||
}
|
}
|
||||||
return s.AppSettings(ctx)
|
return s.AppSettings(ctx)
|
||||||
@@ -274,33 +277,37 @@ func (s *Service) applyDefaultNetwork(ctx context.Context, network NetworkSettin
|
|||||||
return row.Network
|
return row.Network
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) resolveToken(ctx context.Context, input string) (string, error) {
|
func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) {
|
||||||
token := strings.TrimSpace(input)
|
|
||||||
if token != "" {
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
row, err := s.store.GetSettingsRaw(ctx)
|
row, err := s.store.GetSettingsRaw(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
if row.TokenEnc == "" {
|
accountID := strings.TrimSpace(row.AccountID)
|
||||||
return "", fmt.Errorf("请填写 token,或先在「应用配置」中保存默认 token")
|
if accountID == "" {
|
||||||
|
return nil, fmt.Errorf("请先在「应用配置」中填写 Account ID")
|
||||||
}
|
}
|
||||||
plain, err := s.secretBox.Decrypt(row.TokenEnc)
|
if row.APITokenEnc == "" {
|
||||||
|
return nil, fmt.Errorf("请先在「应用配置」中保存 API Token")
|
||||||
|
}
|
||||||
|
token, err := s.secretBox.Decrypt(row.APITokenEnc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("默认 token 解密失败: %w", err)
|
return nil, fmt.Errorf("API Token 解密失败: %w", err)
|
||||||
}
|
}
|
||||||
return plain, nil
|
return NewCloudflareAPI(accountID, token), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyAPIToken 调用 Cloudflare 官方 tokens/verify 接口验证 API Token(cfat_...)。
|
// VerifyAPIToken 用 Account ID + API Token 调用 Cloudflare 账号级 tokens/verify 接口。
|
||||||
func (s *Service) VerifyAPIToken(ctx context.Context, input string) (string, error) {
|
func (s *Service) VerifyAPIToken(ctx context.Context, input string) (string, error) {
|
||||||
token := strings.TrimSpace(input)
|
|
||||||
if token == "" {
|
|
||||||
row, err := s.store.GetSettingsRaw(ctx)
|
row, err := s.store.GetSettingsRaw(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
accountID := strings.TrimSpace(row.AccountID)
|
||||||
|
if accountID == "" {
|
||||||
|
return "", fmt.Errorf("请先填写 Account ID")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(input)
|
||||||
|
if token == "" {
|
||||||
if row.APITokenEnc == "" {
|
if row.APITokenEnc == "" {
|
||||||
return "", fmt.Errorf("请填写 API Token(cfat_...),或先在应用配置中保存")
|
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)
|
return "", fmt.Errorf("API Token 解密失败: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
return NewCloudflareAPI(accountID, token).VerifyToken(ctx)
|
||||||
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 边缘。
|
// TestToken 验证 Account ID + API Token 是否可用。
|
||||||
func (s *Service) TestToken(ctx context.Context, input, kind string) (string, error) {
|
func (s *Service) TestToken(ctx context.Context, input, _ string) (string, error) {
|
||||||
if kind == "api" || strings.HasPrefix(strings.TrimSpace(input), "cfat_") {
|
|
||||||
return s.VerifyAPIToken(ctx, input)
|
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
|
// attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
|
||||||
@@ -503,9 +427,9 @@ func validateInput(in CreateInput) error {
|
|||||||
return fmt.Errorf("隧道名称不能为空")
|
return fmt.Errorf("隧道名称不能为空")
|
||||||
}
|
}
|
||||||
switch in.Mode {
|
switch in.Mode {
|
||||||
case ModeTokenLocal, ModeTokenRemote:
|
case ModeAccountLocal, ModeTokenLocal, ModeTokenRemote:
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("暂不支持该模式,当前仅支持 token-local / token-remote")
|
return fmt.Errorf("不支持的隧道模式: %s", in.Mode)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package cloudflared
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -13,7 +12,7 @@ import (
|
|||||||
"github.com/fonu/fonu/internal/secret"
|
"github.com/fonu/fonu/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAppSettingsDefaultToken(t *testing.T) {
|
func TestAppSettingsAccountAndAPIToken(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
conn, err := db.Open(filepath.Join(dir, "test.db"))
|
conn, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -28,25 +27,17 @@ func TestAppSettingsDefaultToken(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
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{
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !settings.DefaultTokenConfigured || settings.DefaultTokenMasked == "" {
|
if settings.AccountID != "acc-1" || !settings.APITokenConfigured || settings.APITokenMasked == "" {
|
||||||
t.Fatalf("默认 token 应标记为已配置并返回掩码:%#v", settings)
|
t.Fatalf("Account ID 与 API Token 应保存并回显掩码:%#v", settings)
|
||||||
}
|
}
|
||||||
|
api, err := svc.cloudflareAPI(context.Background())
|
||||||
tunnel, err := svc.Create(context.Background(), CreateInput{
|
if err != nil || api.AccountID != "acc-1" || api.APIToken != "cfat_test_token" {
|
||||||
Name: "demo", Mode: ModeTokenLocal, Hostnames: []string{"a.example.com"}, Network: DefaultNetworkSettings(),
|
t.Fatalf("cloudflareAPI 构造失败:%v %#v", err, api)
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("应能使用默认 token 创建隧道: %v", err)
|
|
||||||
}
|
|
||||||
if tunnel.TunnelID != "tun-1" || tunnel.AccountTag != "acc-1" {
|
|
||||||
t.Fatalf("默认 token 解析结果不符:%#v", tunnel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri
|
|||||||
|
|
||||||
// settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。
|
// settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。
|
||||||
type settingsRow struct {
|
type settingsRow struct {
|
||||||
|
AccountID string
|
||||||
TokenEnc string
|
TokenEnc string
|
||||||
APITokenEnc string
|
APITokenEnc string
|
||||||
Mirror string
|
Mirror string
|
||||||
@@ -104,30 +105,30 @@ type settingsRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) {
|
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, `
|
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, '{}')
|
COALESCE(mirror, 'official'), COALESCE(network_json, '{}')
|
||||||
FROM cf_settings WHERE id = 1
|
FROM cf_settings WHERE id = 1
|
||||||
`).Scan(&tokenEnc, &apiTokenEnc, &mirror, &networkJSON)
|
`).Scan(&accountID, &tokenEnc, &apiTokenEnc, &mirror, &networkJSON)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return settingsRow{Mirror: "official", Network: DefaultNetworkSettings()}, nil
|
return settingsRow{Mirror: "official", Network: DefaultNetworkSettings()}, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return settingsRow{}, err
|
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)
|
_ = json.Unmarshal([]byte(networkJSON), &row.Network)
|
||||||
return row, nil
|
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)
|
networkJSON, _ := json.Marshal(network)
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
UPDATE cf_settings
|
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
|
WHERE id = 1
|
||||||
`, tokenEnc, apiTokenEnc, mirror, string(networkJSON))
|
`, accountID, tokenEnc, apiTokenEnc, mirror, string(networkJSON))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Cloudflare 账号 ID:与 API Token 一起用于创建与管理隧道。
|
||||||
|
ALTER TABLE cf_settings ADD COLUMN account_id TEXT NOT NULL DEFAULT '';
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.6.7",
|
"version": "1.6.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"version": "1.6.7",
|
"version": "1.6.8",
|
||||||
"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.7",
|
"version": "1.6.8",
|
||||||
"packageManager": "npm@10.9.4",
|
"packageManager": "npm@10.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1100,6 +1100,7 @@ export interface CfTunnelPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CfAppSettings {
|
export interface CfAppSettings {
|
||||||
|
account_id: string
|
||||||
default_token_configured: boolean
|
default_token_configured: boolean
|
||||||
default_token_masked: string
|
default_token_masked: string
|
||||||
api_token_configured: boolean
|
api_token_configured: boolean
|
||||||
@@ -1109,6 +1110,7 @@ export interface CfAppSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CfAppSettingsInput {
|
export interface CfAppSettingsInput {
|
||||||
|
account_id: string
|
||||||
token: string
|
token: string
|
||||||
api_token: string
|
api_token: string
|
||||||
mirror: string
|
mirror: string
|
||||||
|
|||||||
@@ -39,19 +39,18 @@
|
|||||||
|
|
||||||
<n-modal v-model:show="editorVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" :title="editing ? '编辑隧道' : '新建隧道'">
|
<n-modal v-model:show="editorVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" :title="editing ? '编辑隧道' : '新建隧道'">
|
||||||
<div class="cf-modal__body">
|
<div class="cf-modal__body">
|
||||||
<n-alert :type="form.mode === 'token-remote' ? 'warning' : 'info'" :bordered="false" class="cf-mode-alert">
|
<n-alert type="info" :bordered="false" class="cf-mode-alert">
|
||||||
{{ modeHint }}
|
隧道由 Cloudflare API 创建;Fonu 负责本地配置、进程、日志与健康。
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-alert v-if="!appSettings.default_token_configured" type="warning" :bordered="false" class="cf-mode-alert">
|
<n-alert v-if="!appSettings.account_id || !appSettings.api_token_configured" type="warning" :bordered="false" class="cf-mode-alert">
|
||||||
还没有默认 Token,请先到「应用配置」保存,再创建隧道。
|
请先在「应用配置」中填写 Account ID 与 API Token,再创建隧道。
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-divider title-placement="left">基本信息</n-divider>
|
<n-divider title-placement="left">基本信息</n-divider>
|
||||||
<n-form label-placement="top">
|
<n-form label-placement="top">
|
||||||
<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>
|
</n-form>
|
||||||
<n-divider title-placement="left">暴露规则</n-divider>
|
<n-divider title-placement="left">暴露规则</n-divider>
|
||||||
<n-form v-if="form.mode === 'token-local'" label-placement="top">
|
<n-form label-placement="top">
|
||||||
<n-form-item label="暴露域名">
|
<n-form-item label="暴露域名">
|
||||||
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" />
|
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -59,14 +58,11 @@
|
|||||||
<n-input v-model:value="form.origin_service" placeholder="http://127.0.0.1:80" />
|
<n-input v-model:value="form.origin_service" placeholder="http://127.0.0.1:80" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<n-alert v-else type="warning" :bordered="false" class="cf-mode-alert">
|
|
||||||
云端规则模式下,本地域名与回源配置不生效,请在 Cloudflare 面板管理 ingress。
|
|
||||||
</n-alert>
|
|
||||||
<n-divider title-placement="left">运行</n-divider>
|
<n-divider title-placement="left">运行</n-divider>
|
||||||
<n-form label-placement="top">
|
<n-form label-placement="top">
|
||||||
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
|
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<p class="cf-hint">Token 与网络调优统一在「应用配置」中管理,保存后会应用到新建隧道。</p>
|
<p class="cf-hint">网络调优统一在「应用配置」中管理,保存后会应用到新建隧道。</p>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="cf-modal__footer">
|
<div class="cf-modal__footer">
|
||||||
@@ -122,17 +118,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
|
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
|
||||||
|
|
||||||
<n-divider title-placement="left">Token 配置</n-divider>
|
<n-divider title-placement="left">Cloudflare 账号</n-divider>
|
||||||
<n-form label-placement="top">
|
<n-form label-placement="top">
|
||||||
<n-form-item label="隧道 Token(Tunnel Token)">
|
<n-form-item label="Account ID">
|
||||||
<n-input
|
<n-input v-model:value="settingsForm.account_id" :placeholder="appSettings.account_id || '例如 b590ee0f16701d36f2819b0e3c938e81'" />
|
||||||
v-model:value="settingsForm.token"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:placeholder="appSettings.default_token_configured ? `已配置 ${appSettings.default_token_masked},留空保留` : '粘贴 Cloudflare 隧道 token(eyJ...)'"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="API Token(可选,用于连接验证)">
|
<n-form-item label="API Token">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="settingsForm.api_token"
|
v-model:value="settingsForm.api_token"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -143,12 +134,12 @@
|
|||||||
</n-form>
|
</n-form>
|
||||||
<div class="cf-code-toolbar">
|
<div class="cf-code-toolbar">
|
||||||
<n-button size="small" :loading="tokenTesting" @click="testToken">测试连接</n-button>
|
<n-button size="small" :loading="tokenTesting" @click="testToken">测试连接</n-button>
|
||||||
<n-button size="small" tag="a" href="https://one.dash.cloudflare.com/" target="_blank" rel="noopener">获取 Token</n-button>
|
<n-button size="small" tag="a" href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener">获取 API Token</n-button>
|
||||||
</div>
|
</div>
|
||||||
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
|
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
|
||||||
{{ tokenTestResult }}
|
{{ tokenTestResult }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<p class="cf-hint">隧道 Token 用于 cloudflared 运行;API Token 用于验证 Cloudflare 账号连接(格式 cfat_...)。测试连接优先使用 API Token。</p>
|
<p class="cf-hint">Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道,不再需要手工填写 Tunnel Token。</p>
|
||||||
|
|
||||||
<n-divider title-placement="left">默认网络设置</n-divider>
|
<n-divider title-placement="left">默认网络设置</n-divider>
|
||||||
<n-form label-placement="top">
|
<n-form label-placement="top">
|
||||||
@@ -194,6 +185,7 @@ const tokenTesting = ref(false)
|
|||||||
const tokenTestResult = ref('')
|
const tokenTestResult = ref('')
|
||||||
const tokenTestOk = ref(false)
|
const tokenTestOk = ref(false)
|
||||||
const appSettings = ref<CfAppSettings>({
|
const appSettings = ref<CfAppSettings>({
|
||||||
|
account_id: '',
|
||||||
default_token_configured: false,
|
default_token_configured: false,
|
||||||
default_token_masked: '',
|
default_token_masked: '',
|
||||||
api_token_configured: false,
|
api_token_configured: false,
|
||||||
@@ -201,7 +193,7 @@ const appSettings = ref<CfAppSettings>({
|
|||||||
mirror: 'official',
|
mirror: 'official',
|
||||||
network: emptyNetwork(),
|
network: emptyNetwork(),
|
||||||
})
|
})
|
||||||
const settingsForm = ref<CfAppSettingsInput>({ token: '', api_token: '', mirror: 'official', network: emptyNetwork() })
|
const settingsForm = ref<CfAppSettingsInput>({ account_id: '', token: '', api_token: '', mirror: 'official', network: emptyNetwork() })
|
||||||
const editorVisible = ref(false)
|
const editorVisible = ref(false)
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const editing = ref<CfTunnel | null>(null)
|
const editing = ref<CfTunnel | null>(null)
|
||||||
@@ -213,10 +205,6 @@ const detailLoading = ref(false)
|
|||||||
|
|
||||||
const form = ref<CfTunnelPayload>(emptyForm())
|
const form = ref<CfTunnelPayload>(emptyForm())
|
||||||
|
|
||||||
const modeOptions = [
|
|
||||||
{ label: 'Token + 本地规则(Fonu 生成 ingress)', value: 'token-local' },
|
|
||||||
{ label: 'Token + 云端规则(Cloudflare 面板管理)', value: 'token-remote' },
|
|
||||||
]
|
|
||||||
const protocolOptions = [
|
const protocolOptions = [
|
||||||
{ label: 'HTTP/2(国内推荐)', value: 'http2' },
|
{ label: 'HTTP/2(国内推荐)', value: 'http2' },
|
||||||
{ label: 'QUIC', value: 'quic' },
|
{ label: 'QUIC', value: 'quic' },
|
||||||
@@ -236,14 +224,10 @@ const mirrorOptions = computed(() => {
|
|||||||
const list = mirrors.value.length ? mirrors.value : [{ id: 'official', name: 'GitHub 官方源', base: '' }]
|
const list = mirrors.value.length ? mirrors.value : [{ id: 'official', name: 'GitHub 官方源', base: '' }]
|
||||||
return list.map((item) => ({ label: item.name, value: item.id }))
|
return list.map((item) => ({ label: item.name, value: item.id }))
|
||||||
})
|
})
|
||||||
const modeHint = computed(() => (form.value.mode === 'token-remote'
|
|
||||||
? '云端规则模式:cloudflared 用 --token 启动,ingress 由 Cloudflare 面板管理,Fonu 只负责进程、日志与健康。'
|
|
||||||
: '本地规则模式:Fonu 解码 token 并生成本地 config.yml,域名与回源由下方配置决定。'))
|
|
||||||
|
|
||||||
function emptyForm(): CfTunnelPayload {
|
function emptyForm(): CfTunnelPayload {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
mode: 'token-local',
|
mode: 'account-local',
|
||||||
token: '',
|
token: '',
|
||||||
hostnames: [],
|
hostnames: [],
|
||||||
origin_service: 'http://127.0.0.1:80',
|
origin_service: 'http://127.0.0.1:80',
|
||||||
@@ -310,16 +294,17 @@ async function saveTunnel() {
|
|||||||
message.warning('请填写隧道名称')
|
message.warning('请填写隧道名称')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!editing.value && !appSettings.value.default_token_configured) {
|
if (!appSettings.value.account_id || !appSettings.value.api_token_configured) {
|
||||||
message.warning('请先在「应用配置」中保存默认 Token')
|
message.warning('请先在「应用配置」中填写 Account ID 与 API Token')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.value.mode === 'token-local' && !form.value.hostnames.length) {
|
if (!form.value.hostnames.length) {
|
||||||
message.warning('本地规则模式至少需要一个暴露域名')
|
message.warning('请至少选择一个暴露域名')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload: CfTunnelPayload = {
|
const payload: CfTunnelPayload = {
|
||||||
...form.value,
|
...form.value,
|
||||||
|
mode: editing.value ? form.value.mode : 'account-local',
|
||||||
token: '',
|
token: '',
|
||||||
network: { ...appSettings.value.network },
|
network: { ...appSettings.value.network },
|
||||||
}
|
}
|
||||||
@@ -419,7 +404,7 @@ async function loadAppSettings() {
|
|||||||
try {
|
try {
|
||||||
const result = await api.getCfSettings()
|
const result = await api.getCfSettings()
|
||||||
appSettings.value = result
|
appSettings.value = result
|
||||||
settingsForm.value = { token: '', api_token: '', mirror: result.mirror || 'official', network: { ...result.network } }
|
settingsForm.value = { account_id: result.account_id || '', token: '', api_token: '', mirror: result.mirror || 'official', network: { ...result.network } }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '读取应用配置失败')
|
message.error(error instanceof Error ? error.message : '读取应用配置失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -430,13 +415,8 @@ async function loadAppSettings() {
|
|||||||
async function testToken() {
|
async function testToken() {
|
||||||
tokenTesting.value = true
|
tokenTesting.value = true
|
||||||
tokenTestResult.value = ''
|
tokenTestResult.value = ''
|
||||||
const apiToken = (settingsForm.value.api_token || '').trim()
|
|
||||||
const useAPI = apiToken !== '' || appSettings.value.api_token_configured
|
|
||||||
try {
|
try {
|
||||||
const result = await api.testCfToken(
|
const result = await api.testCfToken((settingsForm.value.api_token || '').trim(), 'api')
|
||||||
useAPI ? apiToken : (settingsForm.value.token || ''),
|
|
||||||
useAPI ? 'api' : 'tunnel',
|
|
||||||
)
|
|
||||||
tokenTestOk.value = true
|
tokenTestOk.value = true
|
||||||
tokenTestResult.value = result.message || '连接成功'
|
tokenTestResult.value = result.message || '连接成功'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -452,7 +432,7 @@ async function saveSettings() {
|
|||||||
try {
|
try {
|
||||||
const result = await api.saveCfSettings(settingsForm.value)
|
const result = await api.saveCfSettings(settingsForm.value)
|
||||||
appSettings.value = result
|
appSettings.value = result
|
||||||
settingsForm.value = { token: '', api_token: '', mirror: result.mirror, network: { ...result.network } }
|
settingsForm.value = { account_id: result.account_id || '', token: '', api_token: '', mirror: result.mirror, network: { ...result.network } }
|
||||||
message.success('应用配置已保存')
|
message.success('应用配置已保存')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '保存应用配置失败')
|
message.error(error instanceof Error ? error.message : '保存应用配置失败')
|
||||||
@@ -475,6 +455,7 @@ async function downloadBinary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function modeText(mode: string) {
|
function modeText(mode: string) {
|
||||||
|
if (mode === 'account-local') return 'API 管理'
|
||||||
if (mode === 'token-remote') return '云端规则'
|
if (mode === 'token-remote') return '云端规则'
|
||||||
if (mode === 'token-local') return '本地规则'
|
if (mode === 'token-local') return '本地规则'
|
||||||
return mode
|
return mode
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<n-alert type="success" :bordered="false" class="example-alert" title="v1.6.7 示例页面验证标识已生效">
|
<n-alert type="success" :bordered="false" class="example-alert" title="v1.6.8 示例页面验证标识已生效">
|
||||||
当前页面由 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.7。
|
看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.8。
|
||||||
</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.7 前端资源已加载" tone="teal">
|
<StatCard label="页面状态" value="已更新" sub="v1.6.8 前端资源已加载" 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.7。</li>
|
<li>页面版本提示更新为 v1.6.8。</li>
|
||||||
<li>「本次升级验证」区块和页面状态同步更新为 v1.6.7。</li>
|
<li>「本次升级验证」区块和页面状态同步更新为 v1.6.8。</li>
|
||||||
<li>验证标识更新为 <span class="mono">v1.6.7-example-page</span>。</li>
|
<li>验证标识更新为 <span class="mono">v1.6.8-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.7'
|
const verificationRound = 'v1.6.8'
|
||||||
const verificationMarker = 'v1.6.7-example-page'
|
const verificationMarker = 'v1.6.8-example-page'
|
||||||
|
|
||||||
async function refreshVersion() {
|
async function refreshVersion() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user