diff --git a/CHANGELOG.md b/CHANGELOG.md index 182b3f1..4f61d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 变更 diff --git a/VERSION b/VERSION index 94fe62c..dc1e644 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.4 +1.6.0 diff --git a/docs/README.md b/docs/README.md index a4c17bd..20d9782 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.5.4) +> 最近更新:2026-09-28(v1.6.0) ## 方案与设计(本目录) diff --git a/docs/cloudflared-integration-plan.md b/docs/cloudflared-integration-plan.md index 40204cb..061a749 100644 --- a/docs/cloudflared-integration-plan.md +++ b/docs/cloudflared-integration-plan.md @@ -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` 后,以下事实会直接改变实施细节: diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index f6debd9..6e69930 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -238,16 +238,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu ## 10. 在线升级验证 -示例页面用于验证升级结果。v1.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`; - 页面显示独立的「本次升级验证」区块; - 「升级验证」显示为「通过」。 diff --git a/internal/api/cloudflare_handler.go b/internal/api/cloudflare_handler.go new file mode 100644 index 0000000..ba8ef59 --- /dev/null +++ b/internal/api/cloudflare_handler.go @@ -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 +} diff --git a/internal/api/deps.go b/internal/api/deps.go index adc903c..e4c42b5 100644 --- a/internal/api/deps.go +++ b/internal/api/deps.go @@ -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 } diff --git a/internal/api/router.go b/internal/api/router.go index a31521d..1c58a32 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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 diff --git a/internal/api/router_auth_test.go b/internal/api/router_auth_test.go index bc0155d..e7439c1 100644 --- a/internal/api/router_auth_test.go +++ b/internal/api/router_auth_test.go @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go index 4336f63..99c235a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 diff --git a/internal/cloudflared/binary.go b/internal/cloudflared/binary.go new file mode 100644 index 0000000..f0c8e7b --- /dev/null +++ b/internal/cloudflared/binary.go @@ -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 +} diff --git a/internal/cloudflared/binary_test.go b/internal/cloudflared/binary_test.go new file mode 100644 index 0000000..8c8a565 --- /dev/null +++ b/internal/cloudflared/binary_test.go @@ -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) + } +} diff --git a/internal/cloudflared/config.go b/internal/cloudflared/config.go new file mode 100644 index 0000000..9fe85a9 --- /dev/null +++ b/internal/cloudflared/config.go @@ -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:] +} diff --git a/internal/cloudflared/config_test.go b/internal/cloudflared/config_test.go new file mode 100644 index 0000000..0e0e392 --- /dev/null +++ b/internal/cloudflared/config_test.go @@ -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) + } +} diff --git a/internal/cloudflared/hidewindow_other.go b/internal/cloudflared/hidewindow_other.go new file mode 100644 index 0000000..e696781 --- /dev/null +++ b/internal/cloudflared/hidewindow_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package cloudflared + +import "syscall" + +func hideWindowAttr() *syscall.SysProcAttr { + return nil +} diff --git a/internal/cloudflared/hidewindow_windows.go b/internal/cloudflared/hidewindow_windows.go new file mode 100644 index 0000000..2bb71e7 --- /dev/null +++ b/internal/cloudflared/hidewindow_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package cloudflared + +import "syscall" + +func hideWindowAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{HideWindow: true} +} diff --git a/internal/cloudflared/manager.go b/internal/cloudflared/manager.go new file mode 100644 index 0000000..cd98747 --- /dev/null +++ b/internal/cloudflared/manager.go @@ -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 +} diff --git a/internal/cloudflared/manager_test.go b/internal/cloudflared/manager_test.go new file mode 100644 index 0000000..2022df2 --- /dev/null +++ b/internal/cloudflared/manager_test.go @@ -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) + } + } +} diff --git a/internal/cloudflared/model.go b/internal/cloudflared/model.go new file mode 100644 index 0000000..8126eb6 --- /dev/null +++ b/internal/cloudflared/model.go @@ -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"` +} diff --git a/internal/cloudflared/service.go b/internal/cloudflared/service.go new file mode 100644 index 0000000..dd756a1 --- /dev/null +++ b/internal/cloudflared/service.go @@ -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 +} diff --git a/internal/cloudflared/store.go b/internal/cloudflared/store.go new file mode 100644 index 0000000..5b38ae6 --- /dev/null +++ b/internal/cloudflared/store.go @@ -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 +} diff --git a/migrations/030_cloudflare_tunnels.sql b/migrations/030_cloudflare_tunnels.sql new file mode 100644 index 0000000..b867ff4 --- /dev/null +++ b/migrations/030_cloudflare_tunnels.sql @@ -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')) +); diff --git a/web/package-lock.json b/web/package-lock.json index 8ecce7f..6d1654a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 978dab6..17822bf 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index cab3a9e..0f3b639 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,6 +37,10 @@ import type { SystemLogEntry, NotifyTestPayload, UpdateStatus, + CfMirror, + CfRuntimeStatus, + CfTunnel, + CfTunnelPayload, } from './types' export function asList(value: T[] | null | undefined): T[] { @@ -474,4 +478,33 @@ export const api = { } return (await response.json()) as { message: string } }, + + listCfTunnels: () => request('/api/cloudflare/tunnels'), + getCfTunnel: (id: number) => request(`/api/cloudflare/tunnels/${id}`), + createCfTunnel: (payload: CfTunnelPayload) => + request('/api/cloudflare/tunnels', { method: 'POST', body: JSON.stringify(payload) }), + updateCfTunnel: (id: number, payload: CfTunnelPayload) => + request(`/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(`/api/cloudflare/tunnels/${id}/start`, { method: 'POST' }), + stopCfTunnel: (id: number) => + request(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }), + restartCfTunnel: (id: number) => + request(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }), + getCfTunnelStatus: (id: number) => + request(`/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'), } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index a01b368..13dd9a4 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 +} diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index 38fd507..2df2b46 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -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 }, diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 4375faa..eb1fb9b 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -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', diff --git a/web/src/views/CloudflareView.vue b/web/src/views/CloudflareView.vue new file mode 100644 index 0000000..9a433f3 --- /dev/null +++ b/web/src/views/CloudflareView.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index 9e16f91..7014eb6 100644 --- a/web/src/views/ExampleView.vue +++ b/web/src/views/ExampleView.vue @@ -8,7 +8,7 @@ - + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -19,7 +19,7 @@ {{ verificationRound }}
- 看到这个新区块,说明前端资源已经从旧版本切换到 v1.5.4。 + 看到这个新区块,说明前端资源已经从旧版本切换到 v1.6.0。
@@ -33,16 +33,16 @@ - +
    -
  • 页面版本提示更新为 v1.5.4。
  • -
  • 「本次升级验证」区块和页面状态同步更新为 v1.5.4。
  • -
  • 验证标识更新为 v1.5.4-example-page。
  • +
  • 页面版本提示更新为 v1.6.0。
  • +
  • 「本次升级验证」区块和页面状态同步更新为 v1.6.0。
  • +
  • 验证标识更新为 v1.6.0-example-page。
@@ -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