feat(cloudflare): 新增 Cloudflare 隧道独立模块并发布 v1.6.0
This commit is contained in:
@@ -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
@@ -1,7 +1,7 @@
|
||||
# Fonu 文档索引
|
||||
|
||||
> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。
|
||||
> 最近更新:2026-09-28(v1.5.4)
|
||||
> 最近更新:2026-09-28(v1.6.0)
|
||||
|
||||
## 方案与设计(本目录)
|
||||
|
||||
|
||||
@@ -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` 后,以下事实会直接改变实施细节:
|
||||
|
||||
@@ -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`;
|
||||
- 页面显示独立的「本次升级验证」区块;
|
||||
- 「升级验证」显示为「通过」。
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'))
|
||||
);
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user