feat(cloudflare): 新增 Cloudflare 隧道独立模块并发布 v1.6.0
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-09-28 16:36:05 +08:00
parent 66081e93d5
commit a8a33e87d2
30 changed files with 2133 additions and 56 deletions
+24
View File
@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file.
## [v1.6.0] - 2026-09-28
### 新增
- **Cloudflare 隧道独立模块(阶段 1–2)**:新增 `internal/cloudflared` 包,覆盖 token 解码、`creds.json` / `config.yml` 生成、ingress 生成器、SQLite 存储、cloudflared 二进制下载(镜像表 + 大小与可执行校验)、子进程托管、日志追加、metrics 端口发现与健康抓取
- 新增 `cf_tunnels` 表与 `/api/cloudflare/*` 接口:列表、创建、更新、删除、启动、停止、重启、状态、日志、配置预览、二进制信息与下载、ingress 候选域名
- 前端新增「Cloudflare 隧道」页面:隧道列表、创建 / 编辑、启停、配置预览、日志、二进制下载与网络调优设置
- 支持 `token-local`(Fonu 生成本地 ingress)与 `token-remote`(Cloudflare 面板管理规则)两种模式;界面显示模式差异
- 网络调优默认值:`http2` / IPv4 / HA 2 / 跟随系统代理;ingress 锁定 catch-all、`127.0.0.1` 归一化与稳定排序三条不变式
### 变更
- 版本号提升到 1.6.0;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化
### 测试
- `internal/cloudflared`:token 解码、ingress 不变式、config.yml、metrics 解析、启动参数、镜像 URL、代理禁用单元测试
- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过
### 未实施
- 账号模式(`cert.pem`)与云端 ingress 的 fetch / push
- 边缘 IP 优选、快速隧道、自动 DNS
## [v1.5.4] - 2026-09-28
### 变更
+1 -1
View File
@@ -1 +1 @@
1.5.4
1.6.0
+1 -1
View File
@@ -1,7 +1,7 @@
# Fonu 文档索引
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
> 最近更新:2026-09-28(v1.5.4)
> 最近更新:2026-09-28(v1.6.0)
## 方案与设计(本目录)
+2
View File
@@ -280,6 +280,8 @@ Cloudflare Tunnel 把「服务端」换成 Cloudflare 的边缘网络:本机 `
> 本节是 2026-09-28 的优化版落地口径。§一~§十一 保留为背景与论证;出现冲突时以本节为准。
> 实施状态(2026-09-28,v1.6.0):阶段 1–2 已实施,包含 token-local / token-remote 两种模式、token 解码与 creds.json / config.yml 生成、ingress 生成器、SQLite 存储、cloudflared 二进制下载、进程托管、日志、metrics 端口发现与健康抓取、`/api/cloudflare/*` 接口与前端页面。阶段 3 的账号模式(cert.pem)与云端 fetch / push、阶段 4 的边缘优选与快速隧道尚未实施。
### 12.1 源码核对新增的关键事实
逐文件核对 `F:\WWW\cf\Tunnel-Cloudflared-linux` 后,以下事实会直接改变实施细节:
+4 -4
View File
@@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu
## 10. 在线升级验证
示例页面用于验证升级结果。v1.5.4 的验证流程:
示例页面用于验证升级结果。v1.6.0 的验证流程:
1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.5.4`。
2. 确认远端 `main` 分支的 `VERSION` 为 `1.6.0`。
3. 在侧栏看到「一键升级」后点击并确认。
4. 等待升级弹窗显示成功,页面自动刷新。
5. 打开侧栏「示例页面」,确认:
- 页面路由为 `/example`;
- 页面显示的版本为 `1.5.4`;
- 页面显示验证标识 `v1.5.4-example-page`;
- 页面显示的版本为 `1.6.0`;
- 页面显示验证标识 `v1.6.0-example-page`;
- 页面显示独立的「本次升级验证」区块;
- 「升级验证」显示为「通过」。
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/fonu/fonu/internal/cloudflared"
)
type CloudflareHandler struct {
svc *cloudflared.Service
}
func NewCloudflareHandler(svc *cloudflared.Service) *CloudflareHandler {
return &CloudflareHandler{svc: svc}
}
func (h *CloudflareHandler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *CloudflareHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Create(w http.ResponseWriter, r *http.Request) {
var input cloudflared.CreateInput
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)
item, err := h.svc.Create(r.Context(), input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
var input cloudflared.CreateInput
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)
item, err := h.svc.Update(r.Context(), id, input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *CloudflareHandler) Start(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Start)
}
func (h *CloudflareHandler) Stop(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Stop)
}
func (h *CloudflareHandler) Restart(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Restart)
}
func (h *CloudflareHandler) tunnelAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) (cloudflared.Tunnel, error)) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := fn(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Status(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
status, err := h.svc.RuntimeStatus(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *CloudflareHandler) Logs(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
lines, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("lines")))
output, err := h.svc.Logs(r.Context(), id, lines)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"output": output})
}
func (h *CloudflareHandler) Config(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
content, err := h.svc.PreviewConfig(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"content": content})
}
func (h *CloudflareHandler) BinaryInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"version": h.svc.BinaryVersion(),
"mirrors": cloudflared.DefaultMirrors(),
})
}
func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) {
var request struct {
Mirror string `json:"mirror"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
version, err := h.svc.DownloadBinary(r.Context(), request.Mirror)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
func (h *CloudflareHandler) IngressCandidates(w http.ResponseWriter, r *http.Request) {
hosts, err := h.svc.IngressCandidates(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"hostnames": hosts})
}
func normalizeCloudflareNetwork(network cloudflared.NetworkSettings) cloudflared.NetworkSettings {
defaults := cloudflared.DefaultNetworkSettings()
if strings.TrimSpace(network.TransportProtocol) == "" {
network.TransportProtocol = defaults.TransportProtocol
}
if strings.TrimSpace(network.EdgeIPVersion) == "" {
network.EdgeIPVersion = defaults.EdgeIPVersion
}
if network.HAConnections <= 0 {
network.HAConnections = defaults.HAConnections
}
if strings.TrimSpace(network.ProxyMode) == "" {
network.ProxyMode = defaults.ProxyMode
}
return network
}
+19 -17
View File
@@ -8,6 +8,7 @@ import (
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/backup"
"github.com/fonu/fonu/internal/chinacidr"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/ddns"
"github.com/fonu/fonu/internal/discovery"
@@ -21,21 +22,22 @@ import (
)
type Deps struct {
Config config.Config
Logger *slog.Logger
Auth *auth.Service
Proxy *service.ProxyService
DDNS *ddns.Service
ACME *acme.Service
Settings *settings.Store
ChinaCIDR *chinacidr.Service
Backup *backup.Service
Metrics *metrics.Store
Discovery *discovery.Service
FRPMulti *frp.Service
Traffic *traffic.Collector
Notify *notify.Service
Updater *updater.Client
StaticFS fs.FS
StartedAt string
Config config.Config
Logger *slog.Logger
Auth *auth.Service
Proxy *service.ProxyService
DDNS *ddns.Service
ACME *acme.Service
Settings *settings.Store
ChinaCIDR *chinacidr.Service
Backup *backup.Service
Metrics *metrics.Store
Discovery *discovery.Service
FRPMulti *frp.Service
Cloudflare *cloudflared.Service
Traffic *traffic.Collector
Notify *notify.Service
Updater *updater.Client
StaticFS fs.FS
StartedAt string
}
+20
View File
@@ -39,6 +39,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
metricsHandler: NewMetricsHandler(deps.Metrics),
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
cloudflareHandler: NewCloudflareHandler(deps.Cloudflare),
updateHandler: NewUpdateHandler(deps.Updater),
authSvc: deps.Auth,
staticFS: deps.StaticFS,
@@ -190,6 +191,24 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
}
if deps.Cloudflare != nil {
cf := r.cloudflareHandler
protect("GET /api/cloudflare/tunnels", cf.List)
protect("POST /api/cloudflare/tunnels", cf.Create)
protect("GET /api/cloudflare/tunnels/{id}", cf.Get)
protect("PUT /api/cloudflare/tunnels/{id}", cf.Update)
protect("DELETE /api/cloudflare/tunnels/{id}", cf.Delete)
protect("POST /api/cloudflare/tunnels/{id}/start", cf.Start)
protect("POST /api/cloudflare/tunnels/{id}/stop", cf.Stop)
protect("POST /api/cloudflare/tunnels/{id}/restart", cf.Restart)
protect("GET /api/cloudflare/tunnels/{id}/status", cf.Status)
protect("GET /api/cloudflare/tunnels/{id}/logs", cf.Logs)
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/ingress-candidates", cf.IngressCandidates)
}
if deps.StaticFS != nil {
mux.Handle("/", r.spaHandler())
}
@@ -224,6 +243,7 @@ type Router struct {
metricsHandler *MetricsHandler
discoveryHandler *DiscoveryHandler
frpmultiHandler *FRPMultiHandler
cloudflareHandler *CloudflareHandler
updateHandler *UpdateHandler
authSvc *auth.Service
staticFS fs.FS
+8 -5
View File
@@ -13,6 +13,7 @@ import (
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db"
"github.com/fonu/fonu/internal/frp"
@@ -42,13 +43,15 @@ func TestProtectedRoutesRequireAuth(t *testing.T) {
cfg := config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
frpSvc := frp.NewService(cfg, frp.NewStore(conn), box, logger, certificate.NewStore(conn))
cfSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), box, logger, nil)
handler, specs := newRouterWithSpecs(Deps{
Config: cfg,
Auth: authSvc,
FRPMulti: frpSvc,
Settings: settings.NewStore(conn),
Logger: logger,
Config: cfg,
Auth: authSvc,
FRPMulti: frpSvc,
Cloudflare: cfSvc,
Settings: settings.NewStore(conn),
Logger: logger,
})
protected, public := 0, 0
+26 -17
View File
@@ -17,6 +17,7 @@ import (
"github.com/fonu/fonu/internal/backup"
"github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/chinacidr"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db"
"github.com/fonu/fonu/internal/ddns"
@@ -42,6 +43,7 @@ type App struct {
server *http.Server
nginx *nginx.Manager
frpMulti *frp.Service
cloudflare *cloudflared.Service
scheduler *scheduler.Scheduler
shutdownFn context.CancelFunc
db interface{ Close() error }
@@ -112,28 +114,34 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
conn.Close()
return nil, err
}
cloudflareSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), secretBox, logger, proxySvc)
if err := cloudflareSvc.EnsureDirs(); err != nil {
conn.Close()
return nil, err
}
startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
updaterClient := updater.NewClient(cfg.UpdaterSocket)
handler := api.NewRouter(api.Deps{
Config: cfg,
Logger: logger,
Auth: authSvc,
Proxy: proxySvc,
DDNS: ddnsSvc,
ACME: acmeSvc,
Settings: settingsStore,
Notify: notifySvc,
ChinaCIDR: chinaCIDRSvc,
Backup: backupSvc,
Metrics: metricsStore,
Discovery: discoverySvc,
FRPMulti: frpMultiSvc,
Traffic: trafficCollector,
Updater: updaterClient,
StaticFS: staticFS,
StartedAt: startedAt,
Config: cfg,
Logger: logger,
Auth: authSvc,
Proxy: proxySvc,
DDNS: ddnsSvc,
ACME: acmeSvc,
Settings: settingsStore,
Notify: notifySvc,
ChinaCIDR: chinaCIDRSvc,
Backup: backupSvc,
Metrics: metricsStore,
Discovery: discoverySvc,
FRPMulti: frpMultiSvc,
Cloudflare: cloudflareSvc,
Traffic: trafficCollector,
Updater: updaterClient,
StaticFS: staticFS,
StartedAt: startedAt,
})
server := &http.Server{
Addr: cfg.ListenAddr,
@@ -148,6 +156,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
frpMultiSvc.SetBaseContext(ctx)
go frpMultiSvc.StartAgentTunnels(ctx)
go frpMultiSvc.StartAgentCertificateRenewal(ctx)
go cloudflareSvc.StartAuto(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 {
intervalMinutes = 5
+142
View File
@@ -0,0 +1,142 @@
package cloudflared
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const minBinarySize = 10 << 20
// Mirror 是 cloudflared 二进制下载镜像。
type Mirror struct {
ID string `json:"id"`
Name string `json:"name"`
Base string `json:"base"`
}
// DefaultMirrors 返回内置镜像表;Base 为空表示 GitHub 官方源。
func DefaultMirrors() []Mirror {
return []Mirror{
{ID: "official", Name: "GitHub 官方源"},
{ID: "gh-proxy-org", Name: "gh-proxy.org", Base: "https://gh-proxy.org/"},
{ID: "gh-proxy-hk", Name: "gh-proxy 香港", Base: "https://hk.gh-proxy.org/"},
{ID: "gh-proxy-cdn", Name: "gh-proxy CDN", Base: "https://cdn.gh-proxy.org/"},
{ID: "gh-proxy-edgeone", Name: "gh-proxy EdgeOne", Base: "https://edgeone.gh-proxy.org/"},
}
}
func findMirror(id string) Mirror {
id = strings.TrimSpace(id)
for _, mirror := range DefaultMirrors() {
if mirror.ID == id {
return mirror
}
}
return DefaultMirrors()[0]
}
func binaryFileName() string {
name := "cloudflared-" + runtime.GOOS + "-" + runtime.GOARCH
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
// DownloadURL 生成指定镜像的下载地址;version 为空表示 latest。
func DownloadURL(mirror Mirror, version string) string {
official := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + binaryFileName()
if strings.TrimSpace(version) != "" {
official = "https://github.com/cloudflare/cloudflared/releases/download/" + strings.TrimSpace(version) + "/" + binaryFileName()
}
if strings.TrimSpace(mirror.Base) == "" {
return official
}
return mirror.Base + official
}
func (s *Service) binaryPath() string {
name := "cloudflared"
if runtime.GOOS == "windows" {
name += ".exe"
}
return filepath.Join(s.cfg.DataDir, "cloudflared", "bin", name)
}
// BinaryVersion 返回本机 cloudflared 版本;未安装返回空串。
func (s *Service) BinaryVersion() string {
path := s.binaryPath()
if _, err := os.Stat(path); err != nil {
return ""
}
out, err := exec.Command(path, "--version").CombinedOutput()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// DownloadBinary 下载并替换本机 cloudflared 二进制。
func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, error) {
mirror := findMirror(mirrorID)
target := s.binaryPath()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return "", err
}
tmp := target + ".download"
_ = os.Remove(tmp)
if err := fetchFile(ctx, DownloadURL(mirror, ""), tmp); err != nil {
_ = os.Remove(tmp)
return "", err
}
info, err := os.Stat(tmp)
if err != nil || info.Size() < minBinarySize {
_ = os.Remove(tmp)
return "", fmt.Errorf("下载文件过小,可能被镜像源截断")
}
if runtime.GOOS != "windows" {
_ = os.Chmod(tmp, 0o755)
}
if out, err := exec.Command(tmp, "--version").CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("cloudflared 二进制不可执行: %s", strings.TrimSpace(string(out)))
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
return "", err
}
return s.BinaryVersion(), nil
}
func fetchFile(ctx context.Context, url, path string) error {
client := &http.Client{Timeout: 5 * time.Minute}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("下载 cloudflared 失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载 cloudflared 失败: HTTP %d", resp.StatusCode)
}
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
return fmt.Errorf("写入 cloudflared 失败: %w", err)
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package cloudflared
import (
"strings"
"testing"
)
func TestDownloadURL(t *testing.T) {
official := DownloadURL(Mirror{ID: "official"}, "")
if !strings.Contains(official, "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-") {
t.Fatalf("官方下载地址不正确:%s", official)
}
proxied := DownloadURL(Mirror{ID: "gh-proxy-org", Base: "https://gh-proxy.org/"}, "")
if !strings.HasPrefix(proxied, "https://gh-proxy.org/https://github.com/cloudflare/cloudflared/releases/latest/download/") {
t.Fatalf("镜像前缀拼接不正确:%s", proxied)
}
}
func TestFindMirrorFallback(t *testing.T) {
if got := findMirror("not-exist"); got.ID != "official" {
t.Fatalf("未知镜像应回退官方源:%#v", got)
}
}
+145
View File
@@ -0,0 +1,145 @@
package cloudflared
import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
)
type tokenPayload struct {
AccountTag string `json:"a"`
TunnelID string `json:"t"`
TunnelSecret string `json:"s"`
}
// DecodeToken 解析 Cloudflare Tunnel token;兼容 a/t/s 与 AccountTag/TunnelID/TunnelSecret 两种字段名。
func DecodeToken(token string) (accountTag, tunnelID, tunnelSecret string, err error) {
clean := strings.NewReplacer("\n", "", "\r", "", " ", "", "\t", "").Replace(strings.TrimSpace(token))
if clean == "" {
return "", "", "", fmt.Errorf("token 为空")
}
if remainder := len(clean) % 4; remainder != 0 {
clean += strings.Repeat("=", 4-remainder)
}
raw, err := base64.StdEncoding.DecodeString(clean)
if err != nil {
return "", "", "", fmt.Errorf("token base64 解码失败: %w", err)
}
var payload tokenPayload
if err := json.Unmarshal(raw, &payload); err != nil || payload.TunnelID == "" {
var alt struct {
AccountTag string `json:"AccountTag"`
TunnelID string `json:"TunnelID"`
TunnelSecret string `json:"TunnelSecret"`
}
if err2 := json.Unmarshal(raw, &alt); err2 != nil {
return "", "", "", fmt.Errorf("token JSON 解析失败: %w", err)
}
payload.AccountTag, payload.TunnelID, payload.TunnelSecret = alt.AccountTag, alt.TunnelID, alt.TunnelSecret
}
if payload.AccountTag == "" || payload.TunnelID == "" || payload.TunnelSecret == "" {
return "", "", "", fmt.Errorf("token 缺少 AccountTag / TunnelID / TunnelSecret")
}
return payload.AccountTag, payload.TunnelID, payload.TunnelSecret, nil
}
// CredentialsJSON 生成 cloudflared 需要的 creds.json 内容。
func CredentialsJSON(accountTag, tunnelID, tunnelSecret string) string {
data, _ := json.MarshalIndent(map[string]string{
"AccountTag": accountTag,
"TunnelID": tunnelID,
"TunnelSecret": tunnelSecret,
}, "", " ")
return string(data)
}
// BuildConfig 生成 locally-managed 的 config.yml;ingress 末条由调用方保证是 catch-all。
func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress []IngressRule) string {
var b strings.Builder
fmt.Fprintf(&b, "tunnel: %s\n", tunnelID)
fmt.Fprintf(&b, "credentials-file: %s\n\n", credsPath)
writeNetworkSection(&b, network)
b.WriteString("ingress:\n")
for _, rule := range ingress {
if rule.Hostname != "" {
fmt.Fprintf(&b, " - hostname: %s\n", rule.Hostname)
fmt.Fprintf(&b, " service: %s\n", rule.Service)
continue
}
fmt.Fprintf(&b, " - service: %s\n", rule.Service)
}
return b.String()
}
func writeNetworkSection(b *strings.Builder, network NetworkSettings) {
protocol := strings.TrimSpace(network.TransportProtocol)
edge := strings.TrimSpace(network.EdgeIPVersion)
hasOrigin := strings.TrimSpace(network.OriginCAPool) != "" || network.NoTLSVerify || strings.TrimSpace(network.HTTPHostHeader) != ""
if protocol != "" && protocol != "auto" {
fmt.Fprintf(b, "protocol: %s\n", protocol)
}
if edge != "" && edge != "auto" {
fmt.Fprintf(b, "edge-ip-version: \"%s\"\n", edge)
}
if network.HAConnections > 0 {
fmt.Fprintf(b, "ha-connections: %d\n", network.HAConnections)
}
if hasOrigin {
b.WriteString("originRequest:\n")
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
fmt.Fprintf(b, " caPool: %s\n", pool)
}
if network.NoTLSVerify {
b.WriteString(" noTLSVerify: true\n")
}
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
fmt.Fprintf(b, " httpHostHeader: %s\n", header)
}
}
if protocol != "" || edge != "" || network.HAConnections > 0 || hasOrigin {
b.WriteString("\n")
}
}
// BuildIngress 从域名列表生成 ingress,锁定三条不变式:末条 catch-all、去重、稳定排序。
func BuildIngress(hostnames []string, service string) []IngressRule {
seen := map[string]bool{}
cleaned := make([]string, 0, len(hostnames))
for _, host := range hostnames {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" || seen[host] {
continue
}
seen[host] = true
cleaned = append(cleaned, host)
}
sort.Strings(cleaned)
out := make([]IngressRule, 0, len(cleaned)+1)
for _, host := range cleaned {
out = append(out, IngressRule{Hostname: host, Service: service})
}
return append(out, IngressRule{Service: "http_status:404"})
}
// NormalizeService 生成回源 service;localhost 归一化为 127.0.0.1。
func NormalizeService(protocol, host string, port int) string {
host = strings.TrimSpace(host)
if host == "localhost" {
host = "127.0.0.1"
}
return fmt.Sprintf("%s://%s:%d", protocol, host, port)
}
// MaskSecret 只保留首尾少量字符,用于 API 回显与日志。
func MaskSecret(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if len(value) <= 8 {
return "********"
}
return value[:4] + "********" + value[len(value)-4:]
}
+70
View File
@@ -0,0 +1,70 @@
package cloudflared
import (
"encoding/base64"
"strings"
"testing"
)
func TestDecodeToken(t *testing.T) {
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
account, tunnel, secret, err := DecodeToken(encoded)
if err != nil {
t.Fatal(err)
}
if account != "account-1" || tunnel != "tunnel-2" || secret != "secret-3" {
t.Fatalf("解码结果不符:%s %s %s", account, tunnel, secret)
}
}
func TestDecodeTokenAlternativeFields(t *testing.T) {
payload := `{"AccountTag":"a","TunnelID":"t","TunnelSecret":"s"}`
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
if _, _, _, err := DecodeToken(encoded); err != nil {
t.Fatalf("兼容字段名解析失败: %v", err)
}
}
func TestDecodeTokenRejectsInvalid(t *testing.T) {
if _, _, _, err := DecodeToken("not-base64"); err == nil {
t.Fatal("非法 token 应报错")
}
}
func TestBuildIngressInvariants(t *testing.T) {
got := BuildIngress([]string{"B.example.com", "a.example.com", "a.example.com", " "}, "http://127.0.0.1:80")
if len(got) != 3 {
t.Fatalf("应生成 2 条域名 + 1 条 catch-all:%#v", got)
}
if got[0].Hostname != "a.example.com" || got[1].Hostname != "b.example.com" {
t.Fatalf("域名应去重并稳定排序:%#v", got)
}
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
t.Fatalf("末条必须是 catch-all:%#v", got[2])
}
}
func TestBuildConfig(t *testing.T) {
config := BuildConfig("tunnel-1", "/data/creds.json", DefaultNetworkSettings(), BuildIngress([]string{"a.example.com"}, "http://127.0.0.1:80"))
for _, want := range []string{
"tunnel: tunnel-1",
"credentials-file: /data/creds.json",
"protocol: http2",
`edge-ip-version: "4"`,
"ha-connections: 2",
"- hostname: a.example.com",
"service: http://127.0.0.1:80",
"- service: http_status:404",
} {
if !strings.Contains(config, want) {
t.Fatalf("config.yml 缺少 %q:\n%s", want, config)
}
}
}
func TestNormalizeService(t *testing.T) {
if got := NormalizeService("http", "localhost", 80); got != "http://127.0.0.1:80" {
t.Fatalf("NormalizeService() = %q", got)
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package cloudflared
import "syscall"
func hideWindowAttr() *syscall.SysProcAttr {
return nil
}
@@ -0,0 +1,9 @@
//go:build windows
package cloudflared
import "syscall"
func hideWindowAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{HideWindow: true}
}
+375
View File
@@ -0,0 +1,375 @@
package cloudflared
import (
"bufio"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/fonu/fonu/internal/config"
)
var metricsPortPattern = regexp.MustCompile(`starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
type processState struct {
cmd *exec.Cmd
status string
lastError string
metricsPort int
metrics RuntimeStatus
logMu sync.Mutex
}
// Manager 托管 cloudflared 子进程、日志与 metrics。
type Manager struct {
cfg config.Config
logger *slog.Logger
mu sync.Mutex
processes map[int64]*processState
}
func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
return &Manager{cfg: cfg, logger: logger, processes: map[int64]*processState{}}
}
func buildRunArgs(tunnel Tunnel, configPath, token string) []string {
args := []string{"tunnel"}
if tunnel.Mode == ModeTokenRemote {
args = append(args, networkArgs(tunnel.Network)...)
return append(args, "run", "--token", token)
}
args = append(args, "--config", configPath)
args = append(args, networkArgs(tunnel.Network)...)
return append(args, "run")
}
func networkArgs(network NetworkSettings) []string {
args := []string{}
if network.HAConnections > 0 {
args = append(args, "--ha-connections", strconv.Itoa(network.HAConnections))
}
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
args = append(args, "--origin-ca-pool", pool)
}
if network.NoTLSVerify {
args = append(args, "--no-tls-verify")
}
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
args = append(args, "--http-host-header", header)
}
return args
}
func buildEnv(network NetworkSettings) []string {
env := os.Environ()
if protocol := strings.TrimSpace(network.TransportProtocol); protocol != "" {
env = append(env, "TUNNEL_TRANSPORT_PROTOCOL="+protocol)
}
if edge := strings.TrimSpace(network.EdgeIPVersion); edge != "" {
env = append(env, "TUNNEL_EDGE_IP_VERSION="+edge)
}
if network.ProxyMode == "disabled" {
env = removeEnv(env, []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"})
}
return env
}
func removeEnv(env []string, names []string) []string {
out := make([]string, 0, len(env))
for _, item := range env {
remove := false
for _, name := range names {
if strings.HasPrefix(item, name+"=") {
remove = true
break
}
}
if !remove {
out = append(out, item)
}
}
return out
}
// Start 启动一条隧道的 cloudflared 子进程。
func (m *Manager) Start(tunnel Tunnel, binaryPath, configPath, token string) error {
m.mu.Lock()
if _, exists := m.processes[tunnel.ID]; exists {
m.mu.Unlock()
return fmt.Errorf("隧道已在运行中")
}
state := &processState{status: "starting"}
m.processes[tunnel.ID] = state
m.mu.Unlock()
logFile, err := os.OpenFile(tunnel.LogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
if err != nil {
m.remove(tunnel.ID)
return err
}
cmd := exec.Command(binaryPath, buildRunArgs(tunnel, configPath, token)...)
cmd.Env = buildEnv(tunnel.Network)
cmd.SysProcAttr = hideWindowAttr()
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return err
}
if err := cmd.Start(); err != nil {
_ = logFile.Close()
m.remove(tunnel.ID)
return fmt.Errorf("启动 cloudflared 失败: %w", err)
}
state.cmd = cmd
go m.pipeLogs(tunnel.ID, stdout, logFile)
go m.pipeLogs(tunnel.ID, stderr, logFile)
go m.wait(tunnel.ID, cmd, logFile)
return nil
}
func (m *Manager) pipeLogs(id int64, reader io.Reader, logFile *os.File) {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
m.writeLog(id, logFile, line)
m.detectMetrics(id, line)
}
}
func (m *Manager) writeLog(id int64, logFile *os.File, line string) {
m.mu.Lock()
state := m.processes[id]
m.mu.Unlock()
if state == nil {
return
}
state.logMu.Lock()
defer state.logMu.Unlock()
_, _ = fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), line)
}
func (m *Manager) detectMetrics(id int64, line string) {
match := metricsPortPattern.FindStringSubmatch(line)
if len(match) != 2 {
return
}
port, err := strconv.Atoi(match[1])
if err != nil || port <= 0 {
return
}
m.mu.Lock()
state := m.processes[id]
if state == nil || state.metricsPort == port {
m.mu.Unlock()
return
}
state.metricsPort = port
m.mu.Unlock()
go m.scrapeMetrics(id, port)
}
func (m *Manager) scrapeMetrics(id int64, port int) {
client := &http.Client{Timeout: 5 * time.Second}
metricsURL := fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
readyURL := fmt.Sprintf("http://127.0.0.1:%d/ready", port)
lastIn, lastOut := 0.0, 0.0
for {
time.Sleep(2 * time.Second)
if !m.running(id) {
return
}
text, err := fetchText(client, metricsURL)
if err != nil {
continue
}
snapshot := parseMetrics(text)
status := RuntimeStatus{
Running: true, Status: "connecting", MetricsPort: port,
Connections: snapshot.connections, LatencyMS: snapshot.latencyMS,
Transport: snapshot.transport,
}
if snapshot.hasQUIC {
status.BandwidthIn = math.Max(0, snapshot.bytesIn-lastIn) / 2
status.BandwidthOut = math.Max(0, snapshot.bytesOut-lastOut) / 2
lastIn, lastOut = snapshot.bytesIn, snapshot.bytesOut
} else {
status.BandwidthNotice = "HTTP/2 官方 metrics 不含字节指标"
}
if snapshot.connections > 0 {
status.Status = "connected"
} else if isReady(client, readyURL) {
status.Status = "reconnecting"
}
m.mu.Lock()
if state := m.processes[id]; state != nil {
state.metrics = status
state.status = status.Status
}
m.mu.Unlock()
}
}
func (m *Manager) running(id int64) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.processes[id] != nil
}
func (m *Manager) wait(id int64, cmd *exec.Cmd, logFile *os.File) {
err := cmd.Wait()
_ = logFile.Close()
m.mu.Lock()
state := m.processes[id]
if state != nil {
if err != nil {
state.status = "failed"
state.lastError = err.Error()
} else {
state.status = "stopped"
}
}
m.mu.Unlock()
}
func (m *Manager) remove(id int64) {
m.mu.Lock()
delete(m.processes, id)
m.mu.Unlock()
}
// Stop 停止隧道子进程。
func (m *Manager) Stop(id int64) error {
m.mu.Lock()
state := m.processes[id]
delete(m.processes, id)
m.mu.Unlock()
if state == nil || state.cmd == nil || state.cmd.Process == nil {
return nil
}
_ = state.cmd.Process.Kill()
return nil
}
// Status 返回运行状态与最近一次 metrics 快照。
func (m *Manager) Status(id int64) RuntimeStatus {
m.mu.Lock()
state := m.processes[id]
m.mu.Unlock()
if state == nil {
return RuntimeStatus{Status: "stopped"}
}
out := state.metrics
out.Status = state.status
out.LastError = state.lastError
out.MetricsPort = state.metricsPort
out.Running = state.status != "stopped" && state.status != "failed"
return out
}
// Logs 读取日志文件末尾若干行。
func (m *Manager) Logs(logPath string, lines int) (string, error) {
if lines <= 0 {
lines = 200
}
data, err := os.ReadFile(logPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
all := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(all) > lines {
all = all[len(all)-lines:]
}
return strings.Join(all, "\n"), nil
}
type metricsSnapshot struct {
connections int
latencyMS float64
bytesIn float64
bytesOut float64
transport string
hasQUIC bool
}
func parseMetrics(text string) metricsSnapshot {
snapshot := metricsSnapshot{transport: "http2"}
var rttSum, rttCount float64
for _, line := range strings.Split(text, "\n") {
switch {
case strings.HasPrefix(line, "cloudflared_tunnel_ha_connections"):
snapshot.connections = int(parseMetricValue(line))
case strings.HasPrefix(line, "quic_client_receive_bytes"):
snapshot.bytesIn += parseMetricValue(line)
snapshot.hasQUIC = true
case strings.HasPrefix(line, "quic_client_sent_bytes"):
snapshot.bytesOut += parseMetricValue(line)
snapshot.hasQUIC = true
case strings.HasPrefix(line, "quic_client_smoothed_rtt"):
rttSum += parseMetricValue(line)
rttCount++
snapshot.hasQUIC = true
}
}
if snapshot.hasQUIC {
snapshot.transport = "quic"
if rttCount > 0 {
snapshot.latencyMS = rttSum / rttCount
}
}
return snapshot
}
func parseMetricValue(line string) float64 {
fields := strings.Fields(line)
if len(fields) == 0 {
return 0
}
value, _ := strconv.ParseFloat(fields[len(fields)-1], 64)
return value
}
func fetchText(client *http.Client, url string) (string, error) {
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return "", err
}
return string(data), nil
}
func isReady(client *http.Client, url string) bool {
resp, err := client.Get(url)
if err != nil {
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode == http.StatusOK
}
+52
View File
@@ -0,0 +1,52 @@
package cloudflared
import (
"strings"
"testing"
)
func TestBuildRunArgs(t *testing.T) {
local := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenLocal, Network: DefaultNetworkSettings()}, "/data/config.yml", ""), " ")
for _, want := range []string{"tunnel", "--config /data/config.yml", "--ha-connections 2", "run"} {
if !strings.Contains(local, want) {
t.Fatalf("本地规则启动参数缺少 %q:%s", want, local)
}
}
remote := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenRemote, Network: DefaultNetworkSettings()}, "", "token-123"), " ")
if strings.Contains(remote, "--config") || !strings.Contains(remote, "run --token token-123") {
t.Fatalf("云端规则模式不应使用 --config:%s", remote)
}
}
func TestParseMetrics(t *testing.T) {
text := strings.Join([]string{
"cloudflared_tunnel_ha_connections 2",
"quic_client_receive_bytes 1000",
"quic_client_sent_bytes 500",
"quic_client_smoothed_rtt 0.02",
}, "\n")
snapshot := parseMetrics(text)
if snapshot.connections != 2 || !snapshot.hasQUIC || snapshot.transport != "quic" {
t.Fatalf("QUIC metrics 解析不符:%#v", snapshot)
}
if snapshot.latencyMS <= 0 {
t.Fatalf("RTT 解析失败:%#v", snapshot)
}
}
func TestParseMetricsHTTP2(t *testing.T) {
snapshot := parseMetrics("cloudflared_tunnel_ha_connections 1")
if snapshot.hasQUIC || snapshot.transport != "http2" || snapshot.connections != 1 {
t.Fatalf("HTTP/2 metrics 解析不符:%#v", snapshot)
}
}
func TestBuildEnvDisablesProxy(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8080")
env := buildEnv(NetworkSettings{ProxyMode: "disabled"})
for _, item := range env {
if strings.HasPrefix(item, "HTTP_PROXY=") {
t.Fatalf("禁用代理时不应保留 HTTP_PROXY:%v", item)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package cloudflared
import "time"
// Mode 是隧道的托管模式;不同模式决定启动参数与规则生效侧。
const (
ModeTokenLocal = "token-local" // token 解码成本地凭据,规则由 Fonu 生成
ModeTokenRemote = "token-remote" // 直接用 --token 启动,规则由 Cloudflare 面板管理
ModeAccountLocal = "account-local" // cert.pem + credentials,规则本地生成,可云端同步
)
// NetworkSettings 是 cloudflared 的网络调优参数。
type NetworkSettings struct {
TransportProtocol string `json:"transport_protocol"` // http2 / quic / auto
EdgeIPVersion string `json:"edge_ip_version"` // 4 / 6 / auto
HAConnections int `json:"ha_connections"`
ProxyMode string `json:"proxy_mode"` // system / disabled
OriginCAPool string `json:"origin_ca_pool"`
NoTLSVerify bool `json:"no_tls_verify"`
HTTPHostHeader string `json:"http_host_header"`
}
// DefaultNetworkSettings 返回国内环境更稳的默认值。
func DefaultNetworkSettings() NetworkSettings {
return NetworkSettings{
TransportProtocol: "http2",
EdgeIPVersion: "4",
HAConnections: 2,
ProxyMode: "system",
}
}
// IngressRule 对应 config.yml 的 ingress 条目。
type IngressRule struct {
Hostname string `json:"hostname,omitempty" yaml:"hostname,omitempty"`
Service string `json:"service" yaml:"service"`
}
// Tunnel 是一条 Cloudflare 隧道。
type Tunnel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
TunnelID string `json:"tunnel_id"`
AccountTag string `json:"account_tag"`
CredentialsEnc string `json:"-"`
CertPEMEnc string `json:"-"`
ConfigPath string `json:"config_path"`
CredsPath string `json:"creds_path"`
LogPath string `json:"log_path"`
Network NetworkSettings `json:"network"`
Status string `json:"status"`
LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"`
AutoStart bool `json:"auto_start"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateInput 是创建 / 更新隧道时的入参。
type CreateInput struct {
Name string `json:"name"`
Mode string `json:"mode"`
Token string `json:"token"`
Hostnames []string `json:"hostnames"`
OriginService string `json:"origin_service"`
Network NetworkSettings `json:"network"`
AutoStart bool `json:"auto_start"`
}
// RuntimeStatus 是隧道运行状态与指标快照。
type RuntimeStatus struct {
Running bool `json:"running"`
Status string `json:"status"`
LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"`
Connections int `json:"connections"`
LatencyMS float64 `json:"latency_ms"`
BandwidthIn float64 `json:"bandwidth_in"`
BandwidthOut float64 `json:"bandwidth_out"`
Transport string `json:"transport"`
BandwidthNotice string `json:"bandwidth_notice,omitempty"`
}
+266
View File
@@ -0,0 +1,266 @@
package cloudflared
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/proxy"
"github.com/fonu/fonu/internal/secret"
)
const defaultOriginService = "http://127.0.0.1:80"
// RuleSource 提供本机反代规则,用于推导 ingress 候选域名。
type RuleSource interface {
List(ctx context.Context) ([]proxy.Rule, error)
}
// Service 是 Cloudflare 隧道模块的门面。
type Service struct {
cfg config.Config
store *Store
secretBox *secret.Box
logger *slog.Logger
manager *Manager
rules RuleSource
}
func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, rules RuleSource) *Service {
moduleLogger := logger.With("module", "CLOUDFLARED")
return &Service{
cfg: cfg,
store: store,
secretBox: secretBox,
logger: moduleLogger,
manager: NewManager(cfg, moduleLogger),
rules: rules,
}
}
func (s *Service) EnsureDirs() error {
return os.MkdirAll(filepath.Join(s.cfg.DataDir, "cloudflared", "bin"), 0o755)
}
func (s *Service) List(ctx context.Context) ([]Tunnel, error) {
return s.store.List(ctx)
}
func (s *Service) Get(ctx context.Context, id int64) (Tunnel, error) {
return s.store.Get(ctx, id)
}
// Create 解析 token、生成 creds.json 与 config.yml,并写入数据库。
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)
if err != nil {
return Tunnel{}, err
}
dir := s.tunnelDir(in.Name)
if err := os.MkdirAll(dir, 0o750); err != nil {
return Tunnel{}, err
}
configPath := filepath.Join(dir, "config.yml")
credsPath := filepath.Join(dir, "creds.json")
logPath := filepath.Join(dir, "cloudflared.log")
origin := strings.TrimSpace(in.OriginService)
if origin == "" {
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
configContent := BuildConfig(tunnelID, credsPath, in.Network, ingress)
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, tunnelID, tunnelSecret)), 0o600); err != nil {
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(configPath, []byte(configContent), 0o640); err != nil {
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(in.Token)
if err != nil {
return Tunnel{}, err
}
return s.store.Create(ctx, in, tunnelID, accountTag, encrypted, "", configPath, credsPath, logPath)
}
// Update 更新隧道配置;token 留空时保留原凭据。
func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, error) {
current, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
_ = s.manager.Stop(id)
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)
if origin == "" {
origin = defaultOriginService
}
configContent := BuildConfig(tunnelID, current.CredsPath, in.Network, BuildIngress(in.Hostnames, origin))
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(configContent), 0o640); err != nil {
return Tunnel{}, err
}
return s.store.Update(ctx, id, in, tunnelID, accountTag, encrypted, current.CertPEMEnc,
current.ConfigPath, current.CredsPath, current.LogPath)
}
func (s *Service) Delete(ctx context.Context, id int64) error {
current, err := s.store.Get(ctx, id)
if err != nil {
return err
}
_ = s.manager.Stop(id)
if dir := filepath.Dir(current.ConfigPath); dir != "" && strings.HasPrefix(dir, s.cfg.DataDir) {
_ = os.RemoveAll(dir)
}
return s.store.Delete(ctx, id)
}
func (s *Service) Start(ctx context.Context, id int64) (Tunnel, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
binaryPath := s.binaryPath()
if _, err := os.Stat(binaryPath); err != nil {
return Tunnel{}, fmt.Errorf("cloudflared 未安装,请先下载二进制")
}
token := ""
if tunnel.Mode == ModeTokenRemote {
plain, err := s.secretBox.Decrypt(tunnel.CredentialsEnc)
if err != nil {
return Tunnel{}, fmt.Errorf("凭据解密失败: %w", err)
}
token = plain
}
if err := s.manager.Start(tunnel, binaryPath, tunnel.ConfigPath, token); err != nil {
_ = s.store.SetRuntime(ctx, id, "failed", err.Error(), 0)
return Tunnel{}, err
}
_ = s.store.SetRuntime(ctx, id, "starting", "", 0)
return s.store.Get(ctx, id)
}
func (s *Service) Stop(ctx context.Context, id int64) (Tunnel, error) {
_ = s.manager.Stop(id)
_ = s.store.SetRuntime(ctx, id, "stopped", "", 0)
return s.store.Get(ctx, id)
}
func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
_, _ = s.Stop(ctx, id)
return s.Start(ctx, id)
}
func (s *Service) RuntimeStatus(ctx context.Context, id int64) (RuntimeStatus, error) {
if _, err := s.store.Get(ctx, id); err != nil {
return RuntimeStatus{}, err
}
return s.manager.Status(id), nil
}
func (s *Service) Logs(ctx context.Context, id int64, lines int) (string, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return "", err
}
return s.manager.Logs(tunnel.LogPath, lines)
}
func (s *Service) PreviewConfig(ctx context.Context, id int64) (string, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return "", err
}
data, err := os.ReadFile(tunnel.ConfigPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return string(data), nil
}
// IngressCandidates 从本机反代规则推导可暴露的域名候选。
func (s *Service) IngressCandidates(ctx context.Context) ([]string, error) {
if s.rules == nil {
return []string{}, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return nil, err
}
hosts := []string{}
for _, rule := range rules {
if !rule.Enabled {
continue
}
for _, group := range rule.PortGroups() {
hosts = append(hosts, group.Hostnames...)
}
}
return hosts, nil
}
// StartAuto 在应用启动时拉起标记了 auto_start 的隧道。
func (s *Service) StartAuto(ctx context.Context) {
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
if !tunnel.AutoStart {
continue
}
if _, err := s.Start(ctx, tunnel.ID); err != nil {
s.logger.Warn("自动启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
}
}
}
func (s *Service) tunnelDir(name string) string {
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
return r
}
return '_'
}, strings.TrimSpace(name))
return filepath.Join(s.cfg.DataDir, "cloudflared", "tunnels", safe)
}
func validateInput(in CreateInput) error {
if strings.TrimSpace(in.Name) == "" {
return fmt.Errorf("隧道名称不能为空")
}
switch in.Mode {
case ModeTokenLocal, ModeTokenRemote:
default:
return fmt.Errorf("暂不支持该模式,当前仅支持 token-local / token-remote")
}
if strings.TrimSpace(in.Token) == "" {
return fmt.Errorf("token 不能为空")
}
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package cloudflared
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
const tunnelSelect = `
SELECT id, name, mode, COALESCE(tunnel_id, ''), COALESCE(account_tag, ''),
COALESCE(credentials_enc, ''), COALESCE(cert_pem_enc, ''),
COALESCE(config_path, ''), COALESCE(creds_path, ''), COALESCE(log_path, ''),
COALESCE(network_json, '{}'), COALESCE(status, 'stopped'), COALESCE(last_error, ''),
COALESCE(metrics_port, 0), COALESCE(auto_start, 0), created_at, updated_at
FROM cf_tunnels`
func (s *Store) List(ctx context.Context) ([]Tunnel, error) {
rows, err := s.db.QueryContext(ctx, tunnelSelect+` ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Tunnel{}
for rows.Next() {
item, err := scanTunnel(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id int64) (Tunnel, error) {
item, err := scanTunnel(s.db.QueryRowContext(ctx, tunnelSelect+` WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Tunnel{}, fmt.Errorf("Cloudflare 隧道不存在")
}
return item, err
}
func (s *Store) Create(ctx context.Context, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
network, _ := json.Marshal(in.Network)
result, err := s.db.ExecContext(ctx, `
INSERT INTO cf_tunnels(name, mode, tunnel_id, account_tag, credentials_enc, cert_pem_enc,
config_path, creds_path, log_path, network_json, status, auto_start, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, datetime('now'))
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart))
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
id, _ := result.LastInsertId()
return s.Get(ctx, id)
}
func (s *Store) Update(ctx context.Context, id int64, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
network, _ := json.Marshal(in.Network)
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels
SET name = ?, mode = ?, tunnel_id = ?, account_tag = ?, credentials_enc = ?,
cert_pem_enc = ?, config_path = ?, creds_path = ?, log_path = ?,
network_json = ?, auto_start = ?, updated_at = datetime('now')
WHERE id = ?
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), id)
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
return s.Get(ctx, id)
}
func (s *Store) Delete(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_tunnels WHERE id = ?`, id)
return err
}
func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError string, metricsPort int) error {
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels SET status = ?, last_error = ?, metrics_port = ?, updated_at = datetime('now')
WHERE id = ?
`, status, lastError, metricsPort, id)
return err
}
func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) {
var item Tunnel
var networkJSON, created, updated string
var autoStart int
err := row.Scan(&item.ID, &item.Name, &item.Mode, &item.TunnelID, &item.AccountTag,
&item.CredentialsEnc, &item.CertPEMEnc, &item.ConfigPath, &item.CredsPath, &item.LogPath,
&networkJSON, &item.Status, &item.LastError, &item.MetricsPort, &autoStart, &created, &updated)
if err != nil {
return Tunnel{}, err
}
item.AutoStart = autoStart == 1
item.Network = DefaultNetworkSettings()
_ = json.Unmarshal([]byte(networkJSON), &item.Network)
item.CreatedAt = parseTime(created)
item.UpdatedAt = parseTime(updated)
return item, nil
}
func parseTime(raw string) time.Time {
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05"} {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed
}
}
return time.Time{}
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func mapUniqueName(err error) error {
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
return fmt.Errorf("Cloudflare 隧道名称已存在")
}
return err
}
+20
View File
@@ -0,0 +1,20 @@
-- Cloudflare Tunnel(cloudflared)独立模块:每条隧道一份本地配置 + 子进程 + 日志。
CREATE TABLE IF NOT EXISTS cf_tunnels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
mode TEXT NOT NULL DEFAULT 'token-local',
tunnel_id TEXT NOT NULL DEFAULT '',
account_tag TEXT NOT NULL DEFAULT '',
credentials_enc TEXT NOT NULL DEFAULT '',
cert_pem_enc TEXT NOT NULL DEFAULT '',
config_path TEXT NOT NULL DEFAULT '',
creds_path TEXT NOT NULL DEFAULT '',
log_path TEXT NOT NULL DEFAULT '',
network_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'stopped',
last_error TEXT NOT NULL DEFAULT '',
metrics_port INTEGER NOT NULL DEFAULT 0,
auto_start INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "fonu-web",
"version": "1.5.4",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fonu-web",
"version": "1.5.4",
"version": "1.6.0",
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-javascript": "^6.2.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "fonu-web",
"private": true,
"version": "1.5.4",
"version": "1.6.0",
"packageManager": "npm@10.9.4",
"type": "module",
"scripts": {
+33
View File
@@ -37,6 +37,10 @@ import type {
SystemLogEntry,
NotifyTestPayload,
UpdateStatus,
CfMirror,
CfRuntimeStatus,
CfTunnel,
CfTunnelPayload,
} from './types'
export function asList<T>(value: T[] | null | undefined): T[] {
@@ -474,4 +478,33 @@ export const api = {
}
return (await response.json()) as { message: string }
},
listCfTunnels: () => request<CfTunnel[]>('/api/cloudflare/tunnels'),
getCfTunnel: (id: number) => request<CfTunnel>(`/api/cloudflare/tunnels/${id}`),
createCfTunnel: (payload: CfTunnelPayload) =>
request<CfTunnel>('/api/cloudflare/tunnels', { method: 'POST', body: JSON.stringify(payload) }),
updateCfTunnel: (id: number, payload: CfTunnelPayload) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
deleteCfTunnel: (id: number) =>
request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}`, { method: 'DELETE' }),
startCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/start`, { method: 'POST' }),
stopCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }),
restartCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }),
getCfTunnelStatus: (id: number) =>
request<CfRuntimeStatus>(`/api/cloudflare/tunnels/${id}/status`),
getCfTunnelLogs: (id: number, lines = 200) =>
request<{ output: string }>(`/api/cloudflare/tunnels/${id}/logs?lines=${lines}`),
getCfTunnelConfig: (id: number) =>
request<{ content: string }>(`/api/cloudflare/tunnels/${id}/config`),
getCfBinary: () => request<{ version: string; mirrors: CfMirror[] }>('/api/cloudflare/binary'),
downloadCfBinary: (mirror: string) =>
request<{ ok: boolean; version: string }>('/api/cloudflare/binary/download', {
method: 'POST',
body: JSON.stringify({ mirror }),
}),
getCfIngressCandidates: () =>
request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'),
}
+58
View File
@@ -1038,3 +1038,61 @@ export interface StatusPagePayload {
unknown: number
routes: StatusPageRoute[]
}
// Cloudflare Tunnel 独立模块
export interface CfNetworkSettings {
transport_protocol: string
edge_ip_version: string
ha_connections: number
proxy_mode: string
origin_ca_pool?: string
no_tls_verify?: boolean
http_host_header?: string
}
export interface CfTunnel {
id: number
name: string
mode: string
tunnel_id: string
account_tag: string
config_path: string
creds_path: string
log_path: string
network: CfNetworkSettings
status: string
last_error?: string
metrics_port?: number
auto_start: boolean
created_at: string
updated_at: string
}
export interface CfRuntimeStatus {
running: boolean
status: string
last_error?: string
metrics_port?: number
connections: number
latency_ms: number
bandwidth_in: number
bandwidth_out: number
transport: string
bandwidth_notice?: string
}
export interface CfMirror {
id: string
name: string
base: string
}
export interface CfTunnelPayload {
name: string
mode: string
token?: string
hostnames: string[]
origin_service?: string
network: CfNetworkSettings
auto_start: boolean
}
+1
View File
@@ -201,6 +201,7 @@ const menuDefs = [
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
{ label: '系统设置', key: 'settings', icon: CogOutline },
+7
View File
@@ -9,6 +9,7 @@ import CertificatesView from '../views/CertificatesView.vue'
import LogsView from '../views/LogsView.vue'
import SettingsView from '../views/SettingsView.vue'
import FrpView from '../views/FrpView.vue'
import CloudflareView from '../views/CloudflareView.vue'
import AgentView from '../views/AgentView.vue'
import ProxyRemoteView from '../views/ProxyRemoteView.vue'
import StatusPageView from '../views/StatusPageView.vue'
@@ -63,6 +64,12 @@ const router = createRouter({
component: AgentView,
meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 fonu-agent 与 frps' },
},
{
path: 'cloudflare',
name: 'cloudflare',
component: CloudflareView,
meta: { title: 'Cloudflare 隧道', description: '通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘' },
},
{
path: 'public-proxy',
name: 'public-proxy',
+379
View File
@@ -0,0 +1,379 @@
<template>
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
<template #actions>
<n-button :loading="binaryLoading" @click="openBinary">cloudflared 二进制</n-button>
<n-button type="primary" @click="openCreate">新建隧道</n-button>
</template>
</PageHeader>
<n-alert v-if="binaryVersion" type="info" :bordered="false" class="cf-alert">
已安装:<span class="mono">{{ binaryVersion }}</span>
</n-alert>
<n-alert v-else type="warning" :bordered="false" class="cf-alert">
尚未安装 cloudflared。请先点右上角「cloudflared 二进制」下载,再创建隧道。
</n-alert>
<FonuCard title="隧道列表" subtitle="token 模式默认使用 Fonu 生成的本地 ingress;云端规则模式请在 Cloudflare 面板管理">
<div v-if="!tunnels.length" class="cf-empty">还没有隧道,点「新建隧道」开始。</div>
<div v-for="item in tunnels" :key="item.id" class="cf-row">
<div class="cf-row__main">
<strong>{{ item.name }}</strong>
<StatusBadge :kind="statusKind(item.status)" :text="statusText(item.status)" />
<span class="cf-meta mono">{{ modeText(item.mode) }}</span>
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
</div>
<div class="cf-row__actions">
<n-button size="small" @click="openDetail(item)">详情</n-button>
<n-button size="small" @click="openEdit(item)">编辑</n-button>
<n-button v-if="item.status !== 'connected' && item.status !== 'starting'" size="small" type="success" @click="startTunnel(item)">启动</n-button>
<n-button v-else size="small" type="warning" @click="stopTunnel(item)">停止</n-button>
<n-button size="small" @click="restartTunnel(item)">重启</n-button>
<n-popconfirm @positive-click="removeTunnel(item)">
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template>
将停止该隧道并删除本地配置、凭据与日志。继续?
</n-popconfirm>
</div>
</div>
</FonuCard>
<n-modal v-model:show="editorVisible" preset="card" class="cf-modal" :title="editing ? '编辑隧道' : '新建隧道'">
<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-select v-model:value="form.mode" :options="modeOptions" />
</n-form-item>
<n-form-item :label="editing ? 'Token(留空保留原值)' : 'Token'">
<n-input v-model:value="form.token" type="password" show-password-on="click" placeholder="Cloudflare 面板生成的隧道 token" />
</n-form-item>
<n-form-item label="暴露域名">
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" />
</n-form-item>
<n-form-item label="回源 service">
<n-input v-model:value="form.origin_service" placeholder="http://127.0.0.1:80" />
</n-form-item>
<n-form-item label="传输协议">
<n-select v-model:value="form.network.transport_protocol" :options="protocolOptions" />
</n-form-item>
<n-form-item label="边缘地址族">
<n-select v-model:value="form.network.edge_ip_version" :options="edgeOptions" />
</n-form-item>
<n-form-item label="HA 连接数"><n-input-number v-model:value="form.network.ha_connections" :min="1" :max="4" /></n-form-item>
<n-form-item label="代理模式">
<n-select v-model:value="form.network.proxy_mode" :options="proxyOptions" />
</n-form-item>
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
</n-form>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="editorVisible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="saveTunnel">保存</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="detailVisible" preset="card" class="cf-modal" :title="detailTunnel?.name || '隧道详情'">
<div v-if="detailTunnel" class="cf-detail">
<div class="cf-detail__row">
<StatusBadge :kind="statusKind(detailStatus?.status || detailTunnel.status)" :text="statusText(detailStatus?.status || detailTunnel.status)" />
<span class="cf-meta mono">连接数 {{ detailStatus?.connections ?? 0 }}</span>
<span class="cf-meta mono">延迟 {{ (detailStatus?.latency_ms ?? 0).toFixed(0) }} ms</span>
<span class="cf-meta mono">{{ detailStatus?.transport || '—' }}</span>
</div>
<p v-if="detailStatus?.bandwidth_notice" class="cf-hint">{{ detailStatus.bandwidth_notice }}</p>
<h4>config.yml</h4>
<pre class="cf-pre">{{ detailConfig || '暂无配置' }}</pre>
<h4>日志</h4>
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="refreshDetail">刷新</n-button>
<n-button type="primary" @click="detailVisible = false">关闭</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="binaryVisible" preset="card" class="cf-modal" title="cloudflared 二进制">
<n-form label-placement="top">
<n-form-item label="当前版本"><span class="mono">{{ binaryVersion || '未安装' }}</span></n-form-item>
<n-form-item label="下载镜像">
<n-select v-model:value="selectedMirror" :options="mirrorOptions" />
</n-form-item>
</n-form>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="binaryVisible = false">取消</n-button>
<n-button type="primary" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { NAlert, NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, useMessage } from 'naive-ui'
import { api, asList } from '../api/client'
import type { 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'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { StatusKind } from '../utils/status'
const message = useMessage()
const tunnels = ref<CfTunnel[]>([])
const binaryVersion = ref('')
const mirrors = ref<CfMirror[]>([])
const candidates = ref<string[]>([])
const loading = ref(false)
const saving = ref(false)
const binaryLoading = ref(false)
const selectedMirror = ref('official')
const editorVisible = ref(false)
const binaryVisible = ref(false)
const detailVisible = ref(false)
const editing = ref<CfTunnel | null>(null)
const detailTunnel = ref<CfTunnel | null>(null)
const detailStatus = ref<CfRuntimeStatus | null>(null)
const detailConfig = ref('')
const detailLogs = ref('')
const form = ref<CfTunnelPayload>(emptyForm())
const modeOptions = [
{ label: 'Token + 本地规则(Fonu 生成 ingress)', value: 'token-local' },
{ label: 'Token + 云端规则(Cloudflare 面板管理)', value: 'token-remote' },
]
const protocolOptions = [
{ label: 'HTTP/2(国内推荐)', value: 'http2' },
{ label: 'QUIC', value: 'quic' },
{ label: '自动', value: 'auto' },
]
const edgeOptions = [
{ label: 'IPv4(推荐)', value: '4' },
{ label: 'IPv6', value: '6' },
{ label: '自动', value: 'auto' },
]
const proxyOptions = [
{ label: '跟随系统环境变量', value: 'system' },
{ label: '禁用代理', value: 'disabled' },
]
const hostnameOptions = computed(() => candidates.value.map((host) => ({ label: host, value: host })))
const mirrorOptions = computed(() => mirrors.value.map((item) => ({ label: item.name, value: item.id })))
function emptyForm(): CfTunnelPayload {
return {
name: '',
mode: 'token-local',
token: '',
hostnames: [],
origin_service: 'http://127.0.0.1:80',
network: { transport_protocol: 'http2', edge_ip_version: '4', ha_connections: 2, proxy_mode: 'system' },
auto_start: false,
}
}
async function load() {
loading.value = true
try {
tunnels.value = asList(await api.listCfTunnels())
} catch (error) {
message.error(error instanceof Error ? error.message : '读取隧道列表失败')
} finally {
loading.value = false
}
}
async function loadBinary() {
try {
const result = await api.getCfBinary()
binaryVersion.value = result.version || ''
mirrors.value = asList(result.mirrors)
} catch {
// 二进制状态不影响列表
}
}
async function loadCandidates() {
try {
candidates.value = (await api.getCfIngressCandidates()).hostnames || []
} catch {
candidates.value = []
}
}
function openCreate() {
editing.value = null
form.value = emptyForm()
editorVisible.value = true
}
function openEdit(item: CfTunnel) {
editing.value = item
form.value = {
name: item.name,
mode: item.mode,
token: '',
hostnames: [],
origin_service: 'http://127.0.0.1:80',
network: { ...item.network },
auto_start: item.auto_start,
}
editorVisible.value = true
}
async function saveTunnel() {
saving.value = true
try {
if (editing.value) {
await api.updateCfTunnel(editing.value.id, form.value)
} else {
await api.createCfTunnel(form.value)
}
editorVisible.value = false
message.success('隧道已保存')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存隧道失败')
} finally {
saving.value = false
}
}
async function startTunnel(item: CfTunnel) {
await runAction('启动', () => api.startCfTunnel(item.id))
}
async function stopTunnel(item: CfTunnel) {
await runAction('停止', () => api.stopCfTunnel(item.id))
}
async function restartTunnel(item: CfTunnel) {
await runAction('重启', () => api.restartCfTunnel(item.id))
}
async function removeTunnel(item: CfTunnel) {
try {
await api.deleteCfTunnel(item.id)
message.success('隧道已删除')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除隧道失败')
}
}
async function runAction(label: string, fn: () => Promise<unknown>) {
try {
await fn()
message.success(`${label}指令已下发`)
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : `${label}失败`)
}
}
async function openDetail(item: CfTunnel) {
detailTunnel.value = item
detailVisible.value = true
await refreshDetail()
}
async function refreshDetail() {
const item = detailTunnel.value
if (!item) return
try {
const [status, config, logs] = await Promise.all([
api.getCfTunnelStatus(item.id),
api.getCfTunnelConfig(item.id),
api.getCfTunnelLogs(item.id, 200),
])
detailStatus.value = status
detailConfig.value = config.content || ''
detailLogs.value = logs.output || ''
} catch (error) {
message.error(error instanceof Error ? error.message : '读取隧道详情失败')
}
}
function openBinary() {
binaryVisible.value = true
}
async function downloadBinary() {
binaryLoading.value = true
try {
const result = await api.downloadCfBinary(selectedMirror.value)
binaryVersion.value = result.version || ''
message.success('cloudflared 已下载并安装')
} catch (error) {
message.error(error instanceof Error ? error.message : '下载 cloudflared 失败')
} finally {
binaryLoading.value = false
}
}
function modeText(mode: string) {
if (mode === 'token-remote') return '云端规则'
if (mode === 'token-local') return '本地规则'
return mode
}
function statusText(status: string) {
return ({
connected: '已连接',
starting: '启动中',
reconnecting: '重连中',
failed: '失败',
stopped: '已停止',
} as Record<string, string>)[status] || status || '未知'
}
function statusKind(status: string): StatusKind {
if (status === 'connected') return 'success'
if (status === 'starting' || status === 'reconnecting') return 'warning'
if (status === 'failed') return 'error'
return 'unknown'
}
onMounted(() => {
void load()
void loadBinary()
void loadCandidates()
})
useVisibilityPolling(() => load(), 5000)
</script>
<style scoped>
.cf-alert { margin-bottom: var(--fonu-space-4); }
.cf-empty { padding: 24px 0; color: var(--fonu-text-muted); }
.cf-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-4);
padding: 12px 0;
border-bottom: 1px solid var(--fonu-border);
}
.cf-row:last-child { border-bottom: none; }
.cf-row__main { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.cf-row__actions { display: flex; gap: 6px; flex-wrap: wrap; }
.cf-meta { font-size: 12px; color: var(--fonu-text-secondary); }
.cf-modal { width: 620px; max-width: 94vw; }
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.cf-detail__row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.cf-hint { font-size: 12px; color: var(--fonu-text-muted); }
.cf-pre {
max-height: 240px;
overflow: auto;
padding: 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
+8 -8
View File
@@ -8,7 +8,7 @@
</template>
</PageHeader>
<n-alert type="success" :bordered="false" class="example-alert" title="v1.5.4 示例页面验证标识已生效">
<n-alert type="success" :bordered="false" class="example-alert" title="v1.6.0 示例页面验证标识已生效">
当前页面由 v{{ version }} 提供,验证标识为 <span class="mono">{{ verificationMarker }}</span>。
如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。
</n-alert>
@@ -19,7 +19,7 @@
<strong class="example-proof__value">{{ verificationRound }}</strong>
</div>
<div class="example-proof__hint">
看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.4。
看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.0。
</div>
</div>
@@ -33,16 +33,16 @@
<StatCard label="本次验证标识" :value="verificationMarker" sub="用于区分升级前后的前端资源" tone="blue" value-small>
<template #icon><n-icon :component="FlashOutline" /></template>
</StatCard>
<StatCard label="页面状态" value="已更新" sub="v1.5.4 前端资源已加载" tone="teal">
<StatCard label="页面状态" value="已更新" sub="v1.6.0 前端资源已加载" tone="teal">
<template #icon><n-icon :component="CheckmarkCircleOutline" /></template>
</StatCard>
</div>
<FonuCard title="本次示例页变化" subtitle="用于确认升级后的页面内容确实发生变化">
<ul class="example-changes">
<li>页面版本提示更新为 v1.5.4。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.5.4。</li>
<li>验证标识更新为 <span class="mono">v1.5.4-example-page</span>。</li>
<li>页面版本提示更新为 v1.6.0。</li>
<li>「本次升级验证」区块和页面状态同步更新为 v1.6.0。</li>
<li>验证标识更新为 <span class="mono">v1.6.0-example-page</span>。</li>
</ul>
</FonuCard>
@@ -87,8 +87,8 @@ const message = useMessage()
const version = ref(__APP_VERSION__)
const checkedAt = ref('')
const refreshing = ref(false)
const verificationRound = 'v1.5.4'
const verificationMarker = 'v1.5.4-example-page'
const verificationRound = 'v1.6.0'
const verificationMarker = 'v1.6.0-example-page'
async function refreshVersion() {
refreshing.value = true