feat(cloudflare): 完成隧道接入、运维与 P0 体验优化

This commit is contained in:
2026-10-01 11:32:54 +08:00
parent 36d8b1e464
commit 83cc9d504b
48 changed files with 5383 additions and 201 deletions
+140 -2
View File
@@ -101,6 +101,10 @@ func (h *CloudflareHandler) Restart(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Restart)
}
func (h *CloudflareHandler) RefreshCredentials(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.RefreshCredentials)
}
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 {
@@ -158,6 +162,102 @@ func (h *CloudflareHandler) Config(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"content": content})
}
func (h *CloudflareHandler) Routes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
routes, err := h.svc.Routes(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, routes)
}
func (h *CloudflareHandler) ReplaceRoutes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
var request struct {
Routes []cloudflared.RouteInput `json:"routes"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
routes, err := h.svc.ReplaceRoutes(r.Context(), id, request.Routes)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, routes)
}
func (h *CloudflareHandler) Drift(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
result, err := h.svc.Drift(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) SyncRoutes(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.PushTunnel(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) Diagnostics(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
result, err := h.svc.Diagnostics(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) Preflight(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.svc.Preflight(r.Context()))
}
func (h *CloudflareHandler) TestRoute(w http.ResponseWriter, r *http.Request) {
var request struct {
Service string `json:"service"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
result, err := h.svc.TestService(r.Context(), request.Service)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) BinaryInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"version": h.svc.BinaryVersion(),
@@ -225,13 +325,32 @@ func (h *CloudflareHandler) SetEnabled(w http.ResponseWriter, r *http.Request) {
func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) {
var request struct {
Mirror string `json:"mirror"`
Mirror string `json:"mirror"`
Version string `json:"version"`
}
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)
version, err := h.svc.DownloadBinaryVersion(r.Context(), request.Mirror, request.Version)
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) LatestBinary(w http.ResponseWriter, r *http.Request) {
version, err := h.svc.LatestBinaryVersion(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"version": version})
}
func (h *CloudflareHandler) RollbackBinary(w http.ResponseWriter, r *http.Request) {
version, err := h.svc.RollbackBinary()
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
@@ -248,6 +367,25 @@ func (h *CloudflareHandler) IngressCandidates(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, map[string]any{"hostnames": hosts})
}
func (h *CloudflareHandler) Zones(w http.ResponseWriter, r *http.Request) {
zones, err := h.svc.Zones(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"zones": zones})
}
func (h *CloudflareHandler) AccessStatus(w http.ResponseWriter, r *http.Request) {
hostname := strings.TrimSpace(r.URL.Query().Get("hostname"))
enabled, name, err := h.svc.AccessStatus(r.Context(), hostname)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": enabled, "name": name})
}
func normalizeCloudflareNetwork(network cloudflared.NetworkSettings) cloudflared.NetworkSettings {
defaults := cloudflared.DefaultNetworkSettings()
if strings.TrimSpace(network.TransportProtocol) == "" {
+17
View File
@@ -36,6 +36,8 @@ type proxyRequest struct {
Name *string `json:"name"`
Remark *string `json:"remark"` // deprecated alias for name
Security *securityRequest `json:"security,omitempty"`
Exits []string `json:"exits"`
CFTunnelID *int64 `json:"cf_tunnel_id"`
}
type proxyReorderRequest struct {
@@ -80,6 +82,8 @@ func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
Enabled: boolDefault(req.Enabled, true),
Name: name,
Security: req.securityInput(),
Exits: req.Exits,
CFTunnelID: int64Default(req.CFTunnelID, 0),
}
rule, err := h.svc.Create(r.Context(), in)
@@ -135,6 +139,12 @@ func (h *ProxyHandler) Update(w http.ResponseWriter, r *http.Request) {
sec := req.securityInput()
in.Security = &sec
}
if req.Exits != nil {
in.Exits = &req.Exits
}
if req.CFTunnelID != nil {
in.CFTunnelID = req.CFTunnelID
}
rule, err := h.svc.Update(r.Context(), id, in)
if err != nil {
@@ -258,3 +268,10 @@ func boolDefault(v *bool, fallback bool) bool {
}
return *v
}
func int64Default(v *int64, fallback int64) int64 {
if v == nil {
return fallback
}
return *v
}
+14 -2
View File
@@ -28,14 +28,14 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
nginxHandler: NewNginxHandler(deps.Proxy),
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.StartedAt, deps.Config.NginxPIDFile),
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.Cloudflare, deps.StartedAt, deps.Config.NginxPIDFile),
ddnsHandler: NewDDNSHandler(deps.DDNS),
certHandler: NewCertHandler(deps.ACME),
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
logsHandler: logsHandler,
backupHandler: NewBackupHandler(deps.Backup, deps.Settings),
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti),
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti, deps.Cloudflare),
metricsHandler: NewMetricsHandler(deps.Metrics),
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
@@ -201,16 +201,28 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
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("POST /api/cloudflare/tunnels/{id}/credentials/refresh", cf.RefreshCredentials)
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/tunnels/{id}/routes", cf.Routes)
protect("PUT /api/cloudflare/tunnels/{id}/routes", cf.ReplaceRoutes)
protect("GET /api/cloudflare/tunnels/{id}/drift", cf.Drift)
protect("POST /api/cloudflare/tunnels/{id}/sync", cf.SyncRoutes)
protect("GET /api/cloudflare/tunnels/{id}/diagnostics", cf.Diagnostics)
protect("GET /api/cloudflare/binary", cf.BinaryInfo)
protect("POST /api/cloudflare/binary/download", cf.DownloadBinary)
protect("GET /api/cloudflare/binary/latest", cf.LatestBinary)
protect("POST /api/cloudflare/binary/rollback", cf.RollbackBinary)
protect("GET /api/cloudflare/settings", cf.AppSettings)
protect("PUT /api/cloudflare/settings", cf.SaveAppSettings)
protect("POST /api/cloudflare/settings/test", cf.TestToken)
protect("POST /api/cloudflare/enabled", cf.SetEnabled)
protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates)
protect("GET /api/cloudflare/zones", cf.Zones)
protect("GET /api/cloudflare/access-status", cf.AccessStatus)
protect("GET /api/cloudflare/preflight", cf.Preflight)
protect("POST /api/cloudflare/routes/test", cf.TestRoute)
}
if deps.StaticFS != nil {
+31 -16
View File
@@ -13,6 +13,7 @@ import (
"github.com/fonu/fonu/internal/acme"
certstore "github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/ddns"
"github.com/fonu/fonu/internal/logstore"
@@ -22,27 +23,29 @@ import (
)
type StatusHandler struct {
cfg config.Config
proxySvc *service.ProxyService
ddnsSvc *ddns.Service
acmeSvc *acme.Service
startedAt string
nginxPIDFile string
cpuMu sync.Mutex
cpuSample sysinfo.CPUSample
cfg config.Config
proxySvc *service.ProxyService
ddnsSvc *ddns.Service
acmeSvc *acme.Service
cloudflareSvc *cloudflared.Service
startedAt string
nginxPIDFile string
cpuMu sync.Mutex
cpuSample sysinfo.CPUSample
}
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, startedAt, nginxPIDFile string) *StatusHandler {
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, cloudflareSvc *cloudflared.Service, startedAt, nginxPIDFile string) *StatusHandler {
// 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0)
_, sample := sysinfo.Read(sysinfo.CPUSample{})
return &StatusHandler{
cfg: cfg,
proxySvc: proxySvc,
ddnsSvc: ddnsSvc,
acmeSvc: acmeSvc,
startedAt: startedAt,
nginxPIDFile: nginxPIDFile,
cpuSample: sample,
cfg: cfg,
proxySvc: proxySvc,
ddnsSvc: ddnsSvc,
acmeSvc: acmeSvc,
cloudflareSvc: cloudflareSvc,
startedAt: startedAt,
nginxPIDFile: nginxPIDFile,
cpuSample: sample,
}
}
@@ -71,6 +74,10 @@ type statusResponse struct {
MemTotalBytes uint64 `json:"mem_total_bytes"`
DiskUsedBytes uint64 `json:"disk_used_bytes"`
DiskTotalBytes uint64 `json:"disk_total_bytes"`
CloudflareCount int `json:"cloudflare_count"`
CloudflareOnline int `json:"cloudflare_online"`
CloudflareStatus string `json:"cloudflare_status"`
CloudflareMessage string `json:"cloudflare_message,omitempty"`
StartedAt string `json:"started_at"`
UptimeSeconds int64 `json:"uptime_seconds"`
}
@@ -97,6 +104,10 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
resources, cpuSample := sysinfo.Read(h.currentCPUSample())
h.setCPUSample(cpuSample)
cfSummary := cloudflared.TunnelSummary{Status: "none", Message: "未创建隧道"}
if h.cloudflareSvc != nil {
cfSummary = h.cloudflareSvc.Summary(r.Context())
}
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
@@ -131,6 +142,10 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
MemTotalBytes: resources.MemTotalBytes,
DiskUsedBytes: resources.DiskUsedBytes,
DiskTotalBytes: resources.DiskTotalBytes,
CloudflareCount: cfSummary.Count,
CloudflareOnline: cfSummary.Online,
CloudflareStatus: cfSummary.Status,
CloudflareMessage: cfSummary.Message,
StartedAt: h.startedAt,
UptimeSeconds: int64(uptime),
})
+100 -21
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"net"
@@ -9,6 +10,7 @@ import (
"sync"
"time"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/notify"
"github.com/fonu/fonu/internal/settings"
@@ -26,25 +28,37 @@ const (
// 刻意只暴露域名与可用率:不带内网地址、端口、证书路径、服务端地址——
// 状态页是要挂到公网上的东西,泄露拓扑就等于把内网结构摊开给别人看。
type StatusPageHandler struct {
settings *settings.Store
frp *frp.Service
limiter *statusPageLimiter
settings *settings.Store
frp *frp.Service
cloudflare *cloudflared.Service
limiter *statusPageLimiter
}
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service) *StatusPageHandler {
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service, cloudflareSvc *cloudflared.Service) *StatusPageHandler {
return &StatusPageHandler{
settings: settingsStore,
frp: frpSvc,
limiter: newStatusPageLimiter(),
settings: settingsStore,
frp: frpSvc,
cloudflare: cloudflareSvc,
limiter: newStatusPageLimiter(),
}
}
type statusPageRoute struct {
Domain string `json:"domain"`
State string `json:"state"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
Domain string `json:"domain"`
State string `json:"state"`
Source string `json:"source,omitempty"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
Incidents []statusPageIncident `json:"incidents"`
}
type statusPageIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
type statusPagePayload struct {
@@ -70,7 +84,7 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
}
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
if !enabled || h.frp == nil {
if !enabled || (h.frp == nil && h.cloudflare == nil) {
writeError(r, w, http.StatusNotFound, "状态页未开启")
return
}
@@ -84,17 +98,30 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
stats, err := h.frp.RouteUptime(r.Context(), 24*time.Hour)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
payload := statusPagePayload{
Title: "Fonu 服务状态",
CodeRequired: codeRequired,
CheckedAt: time.Now().UTC().Format(time.RFC3339),
Routes: make([]statusPageRoute, 0, len(stats)),
Routes: make([]statusPageRoute, 0),
}
if err := appendFRPStatus(&payload, h.frp, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
if err := appendCloudflareStatus(&payload, h.cloudflare, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
writeJSON(w, http.StatusOK, payload)
}
func appendFRPStatus(payload *statusPagePayload, svc *frp.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.RouteUptime(ctx, 24*time.Hour)
if err != nil {
return err
}
for _, stat := range stats {
switch stat.State {
@@ -108,12 +135,64 @@ func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain,
State: stat.State,
Source: "frp",
Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes,
Incidents: mapFRPIncidents(stat.Incidents),
})
}
writeJSON(w, http.StatusOK, payload)
return nil
}
func appendCloudflareStatus(payload *statusPagePayload, svc *cloudflared.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.HealthUptime(ctx, 24*time.Hour)
if err != nil {
return err
}
for _, stat := range stats {
switch stat.State {
case cloudflared.HealthUp:
payload.Up++
case cloudflared.HealthDown:
payload.Down++
default:
payload.Unknown++
}
payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain,
State: stat.State,
Source: "cloudflare",
Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes,
Incidents: mapCloudflareIncidents(stat.Incidents),
})
}
return nil
}
func mapFRPIncidents(items []frp.RouteHealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
}
func mapCloudflareIncidents(items []cloudflared.HealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
}
func hashStatusPageCode(code string) string {
+3 -2
View File
@@ -119,6 +119,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
conn.Close()
return nil, err
}
proxySvc.SetExitSync(cloudflareSvc)
startedAt := time.Now().UTC().Format(time.RFC3339)
trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
updaterClient := updater.NewClient(cfg.UpdaterSocket)
@@ -156,7 +157,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)
go cloudflareSvc.StartAll(ctx)
intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
if intervalMinutes <= 0 {
intervalMinutes = 5
@@ -166,7 +167,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
chinaHours = 24
}
// 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore)
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, cloudflare: cloudflareSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore)
// 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份
backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger)
sched := scheduler.New(
+54 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/monitor"
"github.com/fonu/fonu/internal/traffic"
@@ -13,9 +14,59 @@ import (
// monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」,
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
type monitorSource struct {
frp *frp.Service
logger *slog.Logger
traffic *traffic.Collector
frp *frp.Service
cloudflare *cloudflared.Service
logger *slog.Logger
traffic *traffic.Collector
}
func (s monitorSource) CloudflareRoutes(ctx context.Context) ([]monitor.CloudflareRouteStatus, error) {
if s.cloudflare == nil {
return []monitor.CloudflareRouteStatus{}, nil
}
statuses, err := s.cloudflare.HealthStatuses(ctx)
if err != nil {
return nil, err
}
out := make([]monitor.CloudflareRouteStatus, 0, len(statuses))
for _, item := range statuses {
out = append(out, monitor.CloudflareRouteStatus{
TunnelID: item.TunnelID,
Name: item.Name,
Domain: item.Domain,
State: item.State,
Reason: item.Reason,
Detail: item.Detail,
Known: item.Known,
})
}
return out, nil
}
func (s monitorSource) RecordCloudflareHealth(ctx context.Context, samples []monitor.CloudflareHealthSample) error {
if s.cloudflare == nil {
return nil
}
converted := make([]cloudflared.HealthSample, 0, len(samples))
for _, sample := range samples {
converted = append(converted, cloudflared.HealthSample{
TunnelID: sample.TunnelID,
Name: sample.Name,
Domain: sample.Domain,
State: sample.State,
Reason: sample.Reason,
Detail: sample.Detail,
})
}
return s.cloudflare.RecordHealth(ctx, converted)
}
func (s monitorSource) PruneCloudflareHealth(ctx context.Context) error {
if s.cloudflare == nil {
return nil
}
_, err := s.cloudflare.PruneHealth(ctx)
return err
}
func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error) {
+1 -1
View File
@@ -59,7 +59,7 @@ func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath strin
gw := gzip.NewWriter(file)
tw := tar.NewWriter(gw)
for _, entry := range []string{"fonu.db", "certs", "nginx"} {
for _, entry := range []string{"fonu.db", "certs", "nginx", "cloudflared/tunnels"} {
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
discard()
return err
+40
View File
@@ -88,3 +88,43 @@ func TestRestoreAppliesFixedMode(t *testing.T) {
t.Fatalf("还原后的权限应为 %o,实际 %o", restoreFileMode, got)
}
}
func TestExportIncludesCloudflareTunnelConfig(t *testing.T) {
dataDir := t.TempDir()
tunnelDir := filepath.Join(dataDir, "cloudflared", "tunnels", "home")
if err := os.MkdirAll(tunnelDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tunnelDir, "config.yml"), []byte("tunnel: test\n"), 0o640); err != nil {
t.Fatal(err)
}
path, err := New(dataDir).Export(context.Background())
if err != nil {
t.Fatal(err)
}
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
gr, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer gr.Close()
tr := tar.NewReader(gr)
found := false
for {
header, err := tr.Next()
if err != nil {
break
}
if header.Name == "cloudflared/tunnels/home/config.yml" {
found = true
break
}
}
if !found {
t.Fatal("备份应包含 Cloudflare 隧道配置")
}
}
+1 -1
View File
@@ -101,7 +101,7 @@ func (s *Service) Update(ctx context.Context) error {
return err
}
rules, err := s.proxyStore.ListEnabled(ctx)
rules, err := s.proxyStore.ListEnabledLocal(ctx)
if err != nil {
return err
}
+67 -1
View File
@@ -2,6 +2,7 @@ package cloudflared
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -86,6 +87,11 @@ func (s *Service) BinaryVersion() string {
// DownloadBinary 下载并替换本机 cloudflared 二进制。
func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, error) {
return s.DownloadBinaryVersion(ctx, mirrorID, "")
}
// DownloadBinaryVersion 下载指定版本;version 为空表示最新版。
func (s *Service) DownloadBinaryVersion(ctx context.Context, mirrorID, version string) (string, error) {
mirror := findMirror(mirrorID)
target := s.binaryPath()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
@@ -93,7 +99,7 @@ func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string,
}
tmp := target + ".download"
_ = os.Remove(tmp)
if err := fetchFile(ctx, DownloadURL(mirror, ""), tmp); err != nil {
if err := fetchFile(ctx, DownloadURL(mirror, version), tmp); err != nil {
_ = os.Remove(tmp)
return "", err
}
@@ -109,13 +115,73 @@ func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string,
_ = os.Remove(tmp)
return "", fmt.Errorf("cloudflared 二进制不可执行: %s", strings.TrimSpace(string(out)))
}
previous := target + ".previous"
if _, err := os.Stat(target); err == nil {
_ = os.Remove(previous)
if err := os.Rename(target, previous); err != nil {
_ = os.Remove(tmp)
return "", err
}
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Rename(previous, target)
_ = os.Remove(tmp)
return "", err
}
return s.BinaryVersion(), nil
}
// LatestBinaryVersion 查询 Cloudflare 官方 GitHub Release 的最新版本。
func (s *Service) LatestBinaryVersion(ctx context.Context) (string, error) {
client := &http.Client{Timeout: 20 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/cloudflare/cloudflared/releases/latest", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("读取最新版本失败:HTTP %d", resp.StatusCode)
}
var payload struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return "", err
}
version := strings.TrimSpace(payload.TagName)
if version == "" {
return "", fmt.Errorf("GitHub 未返回 cloudflared 版本")
}
return version, nil
}
// RollbackBinary 恢复上一版 cloudflared 二进制。
func (s *Service) RollbackBinary() (string, error) {
target := s.binaryPath()
previous := target + ".previous"
if _, err := os.Stat(previous); err != nil {
return "", fmt.Errorf("没有可回滚的 cloudflared 版本")
}
failed := target + ".failed"
_ = os.Remove(failed)
if _, err := os.Stat(target); err == nil {
if err := os.Rename(target, failed); err != nil {
return "", err
}
}
if err := os.Rename(previous, target); err != nil {
_ = os.Rename(failed, target)
return "", err
}
_ = os.Remove(failed)
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)
+72 -7
View File
@@ -144,6 +144,19 @@ func (a *CloudflareAPI) PutIngress(ctx context.Context, tunnelID string, ingress
}, nil)
}
// GetIngress 读取云端隧道的 ingress 配置。
func (a *CloudflareAPI) GetIngress(ctx context.Context, tunnelID string) ([]IngressRule, error) {
var result struct {
Config struct {
Ingress []IngressRule `json:"ingress"`
} `json:"config"`
}
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"/configurations", nil, &result); err != nil {
return nil, err
}
return result.Config.Ingress, nil
}
// DeleteTunnel 删除云端隧道。
func (a *CloudflareAPI) DeleteTunnel(ctx context.Context, tunnelID string) error {
return a.do(ctx, http.MethodDelete, "/accounts/"+a.AccountID+"/cfd_tunnel/"+tunnelID+"?cascade=true", nil, nil)
@@ -155,6 +168,15 @@ type Zone struct {
Name string `json:"name"`
}
// ListZones 返回当前 API Token 可访问的 Zone。
func (a *CloudflareAPI) ListZones(ctx context.Context) ([]Zone, error) {
var zones []Zone
if err := a.do(ctx, http.MethodGet, "/zones?per_page=50", nil, &zones); err != nil {
return nil, err
}
return zones, nil
}
// FindZone 从 hostname 逐级向上查找所属 Zone。
func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string, string, error) {
labels := strings.Split(strings.Trim(hostname, "."), ".")
@@ -168,23 +190,66 @@ func (a *CloudflareAPI) FindZone(ctx context.Context, hostname string) (string,
return zones[0].ID, zones[0].Name, nil
}
}
return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认 API Token 具备 Zone:Read 权限", hostname)
return "", "", fmt.Errorf("未找到 %s 对应的 Cloudflare Zone;请确认该域名的根域名已加入当前 Account ID,且 API Token 的 Zone Resources 包含它,并具备 Zone:Read 权限", hostname)
}
// EnsureCNAME 幂等创建或更新指向隧道的 CNAME 记录,并开启代理(橙云)。
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) error {
// EnsureCNAME 幂等创建指向隧道的 CNAME 记录,并开启代理(橙云)。
// 已存在且目标一致的记录会被接管;指向其他目标的记录不会被覆盖。
func (a *CloudflareAPI) EnsureCNAME(ctx context.Context, zoneID, hostname, target string) (string, error) {
var records []struct {
ID string `json:"id"`
ID string `json:"id"`
Content string `json:"content"`
}
path := "/zones/" + zoneID + "/dns_records?type=CNAME&name=" + url.QueryEscape(hostname)
if err := a.do(ctx, http.MethodGet, path, nil, &records); err != nil {
return err
return "", err
}
payload := map[string]any{
"type": "CNAME", "name": hostname, "content": target, "proxied": true, "ttl": 1,
}
if len(records) > 0 {
return a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil)
if !strings.EqualFold(strings.TrimSpace(records[0].Content), strings.TrimSpace(target)) {
return "", fmt.Errorf("DNS 记录 %s 已指向其他目标,未自动覆盖", hostname)
}
if err := a.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+records[0].ID, payload, nil); err != nil {
return "", err
}
return records[0].ID, nil
}
return a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, nil)
var created struct {
ID string `json:"id"`
}
if err := a.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", payload, &created); err != nil {
return "", err
}
if strings.TrimSpace(created.ID) == "" {
return "", fmt.Errorf("Cloudflare 未返回 DNS 记录 ID")
}
return created.ID, nil
}
// DeleteDNSRecord 删除一条明确由 Fonu 接管的 DNS 记录。
func (a *CloudflareAPI) DeleteDNSRecord(ctx context.Context, zoneID, recordID string) error {
return a.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+recordID, nil, nil)
}
type accessApp struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
}
// HasAccessForHostname 只读检测域名是否已有 Access 应用;Token 无权限时返回错误,不修改任何配置。
func (a *CloudflareAPI) HasAccessForHostname(ctx context.Context, hostname string) (bool, string, error) {
var apps []accessApp
if err := a.do(ctx, http.MethodGet, "/accounts/"+a.AccountID+"/access/apps?per_page=100", nil, &apps); err != nil {
return false, "", err
}
wanted := strings.ToLower(strings.TrimSpace(hostname))
for _, app := range apps {
if strings.EqualFold(strings.TrimSpace(app.Domain), wanted) {
return true, app.Name, nil
}
}
return false, "", nil
}
+32
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"sort"
"strings"
"github.com/fonu/fonu/internal/proxy"
)
type tokenPayload struct {
@@ -86,6 +88,9 @@ func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress []
for _, rule := range ingress {
if rule.Hostname != "" {
fmt.Fprintf(&b, " - hostname: %s\n", rule.Hostname)
if path := strings.TrimSpace(rule.Path); path != "" && path != "*" {
fmt.Fprintf(&b, " path: %s\n", path)
}
fmt.Fprintf(&b, " service: %s\n", rule.Service)
continue
}
@@ -144,6 +149,33 @@ func BuildIngress(hostnames []string, service string) []IngressRule {
return append(out, IngressRule{Service: "http_status:404"})
}
// BuildIngressForRules 从反向代理规则派生 ingress,每条域名使用自己的回源地址。
func BuildIngressForRules(rules []proxy.Rule) []IngressRule {
serviceByHost := map[string]string{}
for _, rule := range rules {
if !rule.CloudflareEnabled() {
continue
}
service := strings.TrimSpace(rule.Upstream)
if service == "" {
continue
}
for _, host := range rule.Hostnames() {
serviceByHost[host] = service
}
}
hosts := make([]string, 0, len(serviceByHost))
for host := range serviceByHost {
hosts = append(hosts, host)
}
sort.Strings(hosts)
out := make([]IngressRule, 0, len(hosts)+1)
for _, host := range hosts {
out = append(out, IngressRule{Hostname: host, Service: serviceByHost[host]})
}
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)
+45
View File
@@ -5,6 +5,8 @@ import (
"encoding/base64"
"strings"
"testing"
"github.com/fonu/fonu/internal/proxy"
)
func TestDecodeToken(t *testing.T) {
@@ -68,6 +70,49 @@ func TestBuildIngressInvariants(t *testing.T) {
}
}
func TestBuildIngressForRulesUsesRuleUpstream(t *testing.T) {
rules := []proxy.Rule{
{
Enabled: true,
Upstream: "http://192.168.1.10:8080",
Hosts: []proxy.Host{{Hostname: "b.example.com"}},
Exits: []string{proxy.ExitCloudflare},
CFTunnelID: 9,
},
{
Enabled: true,
Upstream: "http://192.168.1.11:9090",
Hosts: []proxy.Host{{Hostname: "a.example.com"}},
Exits: []string{proxy.ExitLocal, proxy.ExitCloudflare},
CFTunnelID: 9,
},
}
got := BuildIngressForRules(rules)
if len(got) != 3 {
t.Fatalf("应生成 2 条域名 + catch-all:%#v", got)
}
if got[0].Hostname != "a.example.com" || got[0].Service != "http://192.168.1.11:9090" {
t.Fatalf("第一条 ingress 不符:%#v", got[0])
}
if got[1].Hostname != "b.example.com" || got[1].Service != "http://192.168.1.10:8080" {
t.Fatalf("第二条 ingress 不符:%#v", got[1])
}
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
t.Fatalf("末条必须是 catch-all:%#v", got[2])
}
}
func TestBuildIngressForRulesSkipsDisabledAndManualRules(t *testing.T) {
rules := []proxy.Rule{
{Enabled: false, Upstream: "http://127.0.0.1:8080", Hosts: []proxy.Host{{Hostname: "off.example.com"}}, Exits: []string{proxy.ExitCloudflare}, CFTunnelID: 1},
{Enabled: true, Upstream: "http://127.0.0.1:8081", Hosts: []proxy.Host{{Hostname: "local.example.com"}}, Exits: []string{proxy.ExitLocal}},
}
got := BuildIngressForRules(rules)
if len(got) != 1 || got[0].Service != "http_status:404" {
t.Fatalf("停用规则和仅本机规则不应进入 Cloudflare ingress:%#v", got)
}
}
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{
+377
View File
@@ -0,0 +1,377 @@
package cloudflared
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
const (
HealthUp = "up"
HealthDown = "down"
HealthUnknown = "unknown"
HealthRetention = 30 * 24 * time.Hour
)
// HealthStatus 一条域名在当前巡检轮次中的健康快照。
type HealthStatus struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
Known bool
}
// HealthSample 交给落库层的状态样本。
type HealthSample struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
}
// HealthEvent 一条状态变更记录。
type HealthEvent struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
At string `json:"at"`
}
// HealthIncident 一段 Cloudflare 隧道不可用区间。
type HealthIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
// UptimeStat 一条 Cloudflare 域名的可用率与不可用区间。
type UptimeStat struct {
TunnelID int64 `json:"tunnel_id"`
Domain string `json:"domain"`
State string `json:"state"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
CoveredMinutes int `json:"covered_minutes"`
Incidents []HealthIncident `json:"incidents"`
}
// TunnelSummary 仪表盘使用的 Cloudflare 隧道概览。
type TunnelSummary struct {
Count int
Online int
Status string
Message string
LastError string
}
// HealthStatuses 返回所有 Cloudflare 隧道域名的当前健康状态。
func (s *Service) HealthStatuses(ctx context.Context) ([]HealthStatus, error) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return nil, err
}
tunnels, err := s.store.List(ctx)
if err != nil {
return nil, err
}
out := []HealthStatus{}
for i := range tunnels {
tunnel := tunnels[i]
s.attachConfig(&tunnel)
status := s.manager.Status(tunnel.ID)
for _, domain := range tunnel.Hostnames {
out = append(out, healthStatus(settings.Enabled, tunnel, status, domain))
}
}
return out, nil
}
func healthStatus(globalEnabled bool, tunnel Tunnel, status RuntimeStatus, domain string) HealthStatus {
item := HealthStatus{TunnelID: tunnel.ID, Name: tunnel.Name, Domain: domain, State: HealthUnknown, Known: false}
if !globalEnabled {
item.Reason = "disabled"
item.Detail = "Cloudflare 隧道全局托管已停用"
return item
}
if status.Running {
item.Known = true
if status.Status == "connected" {
item.State = HealthUp
return item
}
item.State = HealthDown
item.Reason = "connection"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
if tunnel.AutoStart {
item.Known = true
item.State = HealthDown
item.Reason = "process"
item.Detail = strings.TrimSpace(status.LastError)
return item
}
item.Reason = "stopped"
item.Detail = "隧道未运行,且未启用自动启动"
return item
}
// RecordHealth 只写状态变化,避免每轮巡检都插入重复记录。
func (s *Service) RecordHealth(ctx context.Context, samples []HealthSample) error {
if len(samples) == 0 {
return nil
}
current, err := s.latestHealthStates(ctx)
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
for _, sample := range samples {
domain := strings.ToLower(strings.TrimSpace(sample.Domain))
if sample.TunnelID <= 0 || domain == "" || sample.State == "" {
continue
}
key := healthKey(sample.TunnelID, domain)
if state, ok := current[key]; ok && state == sample.State {
continue
}
if _, err := s.store.db.ExecContext(ctx, `
INSERT INTO cf_health_events(tunnel_id, domain, state, reason, detail, at)
VALUES (?, ?, ?, ?, ?, ?)
`, sample.TunnelID, domain, sample.State, sample.Reason, sample.Detail, now); err != nil {
return err
}
}
return nil
}
func (s *Service) latestHealthStates(ctx context.Context) (map[string]string, error) {
rows, err := s.store.db.QueryContext(ctx, `
SELECT e.tunnel_id, e.domain, e.state
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
states := map[string]string{}
for rows.Next() {
var tunnelID int64
var domain, state string
if err := rows.Scan(&tunnelID, &domain, &state); err != nil {
return nil, err
}
states[healthKey(tunnelID, domain)] = state
}
return states, rows.Err()
}
// HealthUptime 返回窗口内每条 Cloudflare 域名的可用率。
func (s *Service) HealthUptime(ctx context.Context, window time.Duration) ([]UptimeStat, error) {
if window <= 0 {
window = 24 * time.Hour
}
now := time.Now().UTC()
since := now.Add(-window)
events, err := s.healthEventsSince(ctx, since)
if err != nil {
return nil, err
}
return ComputeHealthUptime(events, since, now), nil
}
func (s *Service) healthEventsSince(ctx context.Context, since time.Time) ([]HealthEvent, error) {
stamp := since.UTC().Format(time.RFC3339)
rows, err := s.store.db.QueryContext(ctx, `
SELECT tunnel_id, domain, state, COALESCE(reason, ''), COALESCE(detail, ''), at
FROM cf_health_events WHERE at >= ?
UNION ALL
SELECT e.tunnel_id, e.domain, e.state, COALESCE(e.reason, ''), COALESCE(e.detail, ''), e.at
FROM cf_health_events e
JOIN (
SELECT tunnel_id, domain, MAX(id) AS id
FROM cf_health_events WHERE at < ?
GROUP BY tunnel_id, domain
) m ON m.id = e.id
`, stamp, stamp)
if err != nil {
return nil, err
}
defer rows.Close()
events := []HealthEvent{}
for rows.Next() {
var event HealthEvent
if err := rows.Scan(&event.TunnelID, &event.Domain, &event.State, &event.Reason, &event.Detail, &event.At); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}
// PruneHealth 清理超过保留期的状态变更。
func (s *Service) PruneHealth(ctx context.Context) (int64, error) {
res, err := s.store.db.ExecContext(ctx, `DELETE FROM cf_health_events WHERE at < ?`,
time.Now().UTC().Add(-HealthRetention).Format(time.RFC3339))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// Summary 返回隧道数量、在线数与最近错误。
func (s *Service) Summary(ctx context.Context) TunnelSummary {
tunnels, err := s.store.List(ctx)
if err != nil {
return TunnelSummary{Status: "error", Message: err.Error()}
}
summary := TunnelSummary{Count: len(tunnels), Status: "ok"}
if len(tunnels) == 0 {
summary.Status = "none"
summary.Message = "未创建隧道"
return summary
}
for _, tunnel := range tunnels {
status := s.manager.Status(tunnel.ID)
if status.Running && status.Status == "connected" {
summary.Online++
}
if strings.TrimSpace(tunnel.LastError) != "" {
summary.LastError = tunnel.LastError
}
}
if summary.Online == 0 {
summary.Status = "error"
summary.Message = "没有在线隧道"
} else if summary.Online < summary.Count {
summary.Status = "warning"
summary.Message = fmt.Sprintf("%d/%d 条在线", summary.Online, summary.Count)
} else {
summary.Message = fmt.Sprintf("%d 条在线", summary.Online)
}
return summary
}
func healthKey(tunnelID int64, domain string) string {
return fmt.Sprintf("%d|%s", tunnelID, strings.ToLower(strings.TrimSpace(domain)))
}
// ComputeHealthUptime 与 FRP 侧同样的口径:unknown 不计入可用率分母。
type healthPoint struct {
at time.Time
tunnelID int64
domain string
state string
reason, detail string
}
func ComputeHealthUptime(events []HealthEvent, from, to time.Time) []UptimeStat {
byRule := map[string][]healthPoint{}
order := make([]string, 0, len(events))
for _, event := range events {
at, err := time.Parse(time.RFC3339, event.At)
if err != nil {
continue
}
key := healthKey(event.TunnelID, event.Domain)
if _, ok := byRule[key]; !ok {
order = append(order, key)
}
byRule[key] = append(byRule[key], healthPoint{
at: at, tunnelID: event.TunnelID, domain: event.Domain,
state: event.State, reason: event.Reason, detail: event.Detail,
})
}
sort.Strings(order)
stats := make([]UptimeStat, 0, len(order))
for _, key := range order {
points := byRule[key]
sort.Slice(points, func(i, j int) bool { return points[i].at.Before(points[j].at) })
stat := computeHealthPoints(points, from, to)
if stat.Domain != "" {
stats = append(stats, stat)
}
}
return stats
}
func computeHealthPoints(points []healthPoint, from, to time.Time) UptimeStat {
start := from
index := 0
state, reason, detail := "", "", ""
for index < len(points) && !points[index].at.After(from) {
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
if state == "" {
if index >= len(points) {
return UptimeStat{}
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
stat := UptimeStat{TunnelID: points[0].tunnelID, Domain: points[0].domain, State: state, Incidents: []HealthIncident{}}
upMinutes := 0
for {
end := to
ongoing := true
if index < len(points) {
end = points[index].at
ongoing = false
}
minutes := int(end.Sub(start).Minutes())
if minutes < 0 {
minutes = 0
}
switch state {
case HealthUp:
upMinutes += minutes
case HealthDown:
stat.DownMinutes += minutes
incident := HealthIncident{From: start.UTC().Format(time.RFC3339), Reason: reason, Detail: detail, Minutes: minutes}
if !ongoing {
incident.To = end.UTC().Format(time.RFC3339)
}
stat.Incidents = append(stat.Incidents, incident)
default:
stat.UnknownMinutes += minutes
}
stat.CoveredMinutes += minutes
stat.State = state
if index >= len(points) {
break
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
observed := upMinutes + stat.DownMinutes
if observed > 0 {
stat.Uptime = float64(upMinutes) / float64(observed) * 100
} else {
stat.Uptime = 100
if stat.UnknownMinutes > 0 {
stat.Uptime = 0
}
}
return stat
}
+57
View File
@@ -0,0 +1,57 @@
package cloudflared
import (
"context"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db"
)
func TestComputeHealthUptimeExcludesUnknown(t *testing.T) {
from := time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
to := from.Add(2 * time.Hour)
events := []HealthEvent{
{TunnelID: 1, Domain: "a.example.com", State: HealthUnknown, At: from.Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthUp, At: from.Add(30 * time.Minute).Format(time.RFC3339)},
{TunnelID: 1, Domain: "a.example.com", State: HealthDown, At: from.Add(90 * time.Minute).Format(time.RFC3339)},
}
stats := ComputeHealthUptime(events, from, to)
if len(stats) != 1 {
t.Fatalf("应返回一条统计:%#v", stats)
}
if got := stats[0].Uptime; got < 66.6 || got > 66.7 || stats[0].UnknownMinutes != 30 || stats[0].DownMinutes != 30 {
t.Fatalf("可用率计算不符:%#v", stats[0])
}
}
func TestRecordHealthSkipsUnchangedState(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), nil, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
sample := []HealthSample{{TunnelID: 1, Domain: "a.example.com", State: HealthUp}}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
if err := svc.RecordHealth(context.Background(), sample); err != nil {
t.Fatal(err)
}
var count int
if err := conn.QueryRow(`SELECT COUNT(*) FROM cf_health_events`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("同状态不应重复写入,实际 %d 条", count)
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import (
"github.com/fonu/fonu/internal/config"
)
var metricsPortPattern = regexp.MustCompile(`starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
var metricsPortPattern = regexp.MustCompile(`(?i)starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
type processState struct {
cmd *exec.Cmd
+7
View File
@@ -41,6 +41,13 @@ func TestParseMetricsHTTP2(t *testing.T) {
}
}
func TestMetricsPortPatternCaseInsensitive(t *testing.T) {
match := metricsPortPattern.FindStringSubmatch("2026-09-30T18:00:00Z INF Starting metrics server on 127.0.0.1:20241/metrics")
if len(match) != 2 || match[1] != "20241" {
t.Fatalf("应识别大写 Starting 的 metrics 端口:%#v", match)
}
}
func TestBuildEnvDisablesProxy(t *testing.T) {
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8080")
env := buildEnv(NetworkSettings{ProxyMode: "disabled"})
+3
View File
@@ -33,6 +33,7 @@ func DefaultNetworkSettings() NetworkSettings {
// IngressRule 对应 config.yml 的 ingress 条目。
type IngressRule struct {
Hostname string `json:"hostname,omitempty" yaml:"hostname,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
Service string `json:"service" yaml:"service"`
}
@@ -53,6 +54,7 @@ type Tunnel struct {
LastError string `json:"last_error"`
MetricsPort int `json:"metrics_port"`
AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
Hostnames []string `json:"hostnames"`
OriginService string `json:"origin_service"`
DNSWarning string `json:"dns_warning,omitempty"`
@@ -69,6 +71,7 @@ type CreateInput struct {
OriginService string `json:"origin_service"`
Network NetworkSettings `json:"network"`
AutoStart bool `json:"auto_start"`
Managed bool `json:"managed"`
}
// AppSettings 是模块级配置;默认 token 只回显掩码,不返回明文。
+323
View File
@@ -0,0 +1,323 @@
package cloudflared
import (
"context"
"fmt"
"net"
"net/url"
"os"
"sort"
"strings"
"time"
)
// DriftResult 是本地规则派生 ingress 与云端配置的差异。
type DriftResult struct {
TunnelID int64 `json:"tunnel_id"`
Managed bool `json:"managed"`
HasDrift bool `json:"has_drift"`
Items []string `json:"items"`
}
// DiagnosticCheck 一项诊断结果。
type DiagnosticCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
}
// DiagnosticsResult 是 Cloudflare 隧道的一键诊断结果。
type DiagnosticsResult struct {
TunnelID int64 `json:"tunnel_id"`
GeneratedAt string `json:"generated_at"`
Checks []DiagnosticCheck `json:"checks"`
}
// PreflightCheck 是接入向导的一项预检结果。
type PreflightCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Action string `json:"action,omitempty"`
}
// PreflightResult 是 Cloudflare 接入前的整体检查结果。
type PreflightResult struct {
Ready bool `json:"ready"`
Checks []PreflightCheck `json:"checks"`
}
// Preflight 检查新增 Cloudflare 出口前最容易出错的配置。
func (s *Service) Preflight(ctx context.Context) PreflightResult {
binary := s.binaryCheck()
checks := []PreflightCheck{{Name: binary.Name, Status: binary.Status, Message: binary.Message}}
row, err := s.store.GetSettingsRaw(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "应用配置", Status: "error", Message: err.Error()})}
}
checks = append(checks, PreflightCheck{
Name: "Account ID", Status: statusBool(strings.TrimSpace(row.AccountID) != ""), Message: firstNonEmpty(row.AccountID, "未填写 Account ID"), Action: "settings",
})
checks = append(checks, PreflightCheck{
Name: "API Token", Status: statusBool(row.APITokenEnc != ""), Message: boolText(row.APITokenEnc != "", "已保存 API Token", "未保存 API Token"), Action: "settings",
})
if row.APITokenEnc == "" {
return PreflightResult{Ready: false, Checks: checks}
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Cloudflare API", Status: "error", Message: err.Error(), Action: "settings"})}
}
zones, err := api.ListZones(ctx)
if err != nil {
return PreflightResult{Ready: false, Checks: append(checks, PreflightCheck{Name: "Zone 权限", Status: "error", Message: err.Error(), Action: "settings"})}
}
checks = append(checks, PreflightCheck{
Name: "Zone 权限", Status: statusBool(len(zones) > 0), Message: fmt.Sprintf("可访问 %d 个 Cloudflare Zone", len(zones)), Action: "settings",
})
return PreflightResult{Ready: !hasPreflightError(checks), Checks: checks}
}
func statusBool(ok bool) string {
if ok {
return "ok"
}
return "error"
}
func boolText(ok bool, yes, no string) string {
if ok {
return yes
}
return no
}
func firstNonEmpty(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
func hasPreflightError(checks []PreflightCheck) bool {
for _, check := range checks {
if check.Status == "error" {
return true
}
}
return false
}
// Drift 比较本地规则派生 ingress 与 Cloudflare 云端配置。
func (s *Service) Drift(ctx context.Context, id int64) (DriftResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DriftResult{}, err
}
result := DriftResult{TunnelID: id, Managed: tunnel.Managed, Items: []string{}}
if !tunnel.Managed {
return result, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return DriftResult{}, err
}
expected := BuildIngressForRules(rulesForTunnel(rules, tunnel.ID))
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DriftResult{}, err
}
actual, err := api.GetIngress(ctx, tunnel.TunnelID)
if err != nil {
return DriftResult{}, err
}
result.Items = compareIngress(expected, actual)
result.HasDrift = len(result.Items) > 0
return result, nil
}
// PushTunnel 以本地规则为准重新覆盖云端 ingress。
func (s *Service) PushTunnel(ctx context.Context, id int64) error {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return err
}
if !tunnel.Managed {
return fmt.Errorf("手工隧道不支持从规则覆盖,请先在编辑中开启自动托管")
}
return s.syncManagedTunnel(ctx, tunnel)
}
func compareIngress(expected, actual []IngressRule) []string {
expectedMap := ingressMap(expected)
actualMap := ingressMap(actual)
items := []string{}
keys := unionKeys(expectedMap, actualMap)
for _, key := range keys {
want, wantOK := expectedMap[key]
got, gotOK := actualMap[key]
switch {
case wantOK && !gotOK:
items = append(items, fmt.Sprintf("%s 本地存在,云端缺失", key))
case !wantOK && gotOK:
items = append(items, fmt.Sprintf("%s 云端存在,本地已移除", key))
case want != got:
items = append(items, fmt.Sprintf("%s 回源不一致:本地 %s,云端 %s", key, want, got))
}
}
return items
}
func ingressMap(ingress []IngressRule) map[string]string {
out := map[string]string{}
for _, item := range ingress {
key := strings.ToLower(strings.TrimSpace(item.Hostname))
if key == "" {
key = "(catch-all)"
}
out[key] = strings.TrimSpace(item.Service)
}
return out
}
func unionKeys(a, b map[string]string) []string {
seen := map[string]struct{}{}
for key := range a {
seen[key] = struct{}{}
}
for key := range b {
seen[key] = struct{}{}
}
keys := make([]string, 0, len(seen))
for key := range seen {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// Diagnostics 返回一组面向用户的 Cloudflare 隧道检查项。
func (s *Service) Diagnostics(ctx context.Context, id int64) (DiagnosticsResult, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return DiagnosticsResult{}, err
}
result := DiagnosticsResult{TunnelID: id, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Checks: []DiagnosticCheck{}}
result.Checks = append(result.Checks, s.binaryCheck())
result.Checks = append(result.Checks, configCheck(tunnel.ConfigPath))
result.Checks = append(result.Checks, processCheck(s.manager.Status(id), tunnel))
api := s.apiDiagnostic(ctx, tunnel)
result.Checks = append(result.Checks, api)
result.Checks = append(result.Checks, s.dnsCheck(ctx, tunnel))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region1", "region1.v2.argotunnel.com:7844"))
result.Checks = append(result.Checks, networkCheck("Cloudflare edge region2", "region2.v2.argotunnel.com:7844"))
return result, nil
}
func (s *Service) binaryCheck() DiagnosticCheck {
version := s.BinaryVersion()
if strings.TrimSpace(version) == "" {
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "error", Message: "未安装或不可执行"}
}
return DiagnosticCheck{Name: "cloudflared 二进制", Status: "ok", Message: version}
}
func configCheck(path string) DiagnosticCheck {
if _, err := os.Stat(path); err != nil {
return DiagnosticCheck{Name: "本地配置", Status: "error", Message: "config.yml 不存在或不可读"}
}
return DiagnosticCheck{Name: "本地配置", Status: "ok", Message: "config.yml 可读"}
}
func processCheck(status RuntimeStatus, tunnel Tunnel) DiagnosticCheck {
if status.Running && status.Status == "connected" {
return DiagnosticCheck{Name: "隧道进程", Status: "ok", Message: fmt.Sprintf("已连接,HA 连接数 %d", status.Connections)}
}
if tunnel.AutoStart {
return DiagnosticCheck{Name: "隧道进程", Status: "error", Message: "未运行或未连接:" + status.LastError}
}
return DiagnosticCheck{Name: "隧道进程", Status: "warning", Message: "当前未运行,且未启用自动启动"}
}
func (s *Service) apiDiagnostic(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
if tunnel.TunnelID == "" {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: "隧道 ID 为空"}
}
if _, err := api.GetIngress(ctx, tunnel.TunnelID); err != nil {
return DiagnosticCheck{Name: "Cloudflare API", Status: "error", Message: err.Error()}
}
return DiagnosticCheck{Name: "Cloudflare API", Status: "ok", Message: "API Token 与云端配置可访问"}
}
func (s *Service) dnsCheck(ctx context.Context, tunnel Tunnel) DiagnosticCheck {
records, err := s.store.ListDNSRecords(ctx, tunnel.ID)
if err != nil {
return DiagnosticCheck{Name: "DNS 记录", Status: "error", Message: err.Error()}
}
if len(records) == 0 {
return DiagnosticCheck{Name: "DNS 记录", Status: "warning", Message: "没有 Fonu 接管的 DNS 记录"}
}
return DiagnosticCheck{Name: "DNS 记录", Status: "ok", Message: fmt.Sprintf("已接管 %d 条记录", len(records))}
}
func networkCheck(name, address string) DiagnosticCheck {
conn, err := net.DialTimeout("tcp", address, 3*time.Second)
if err != nil {
return DiagnosticCheck{Name: name, Status: "warning", Message: "连接失败:" + err.Error()}
}
_ = conn.Close()
return DiagnosticCheck{Name: name, Status: "ok", Message: "TCP 7844 可连接"}
}
// RouteTestResult 是单条路由源服务的连通性测试结果。
type RouteTestResult struct {
OK bool `json:"ok"`
Message string `json:"message"`
LatencyMS float64 `json:"latency_ms"`
}
// TestService 测试一条路由 service 的 TCP 连通性。
func (s *Service) TestService(ctx context.Context, service string) (RouteTestResult, error) {
address, err := serviceAddress(service)
if err != nil {
return RouteTestResult{}, err
}
start := time.Now()
dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return RouteTestResult{OK: false, Message: err.Error()}, nil
}
_ = conn.Close()
return RouteTestResult{OK: true, Message: "源服务 TCP 可连接", LatencyMS: float64(time.Since(start).Milliseconds())}, nil
}
func serviceAddress(raw string) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
if !strings.Contains(raw, ":") {
return "", fmt.Errorf("service 格式无效:%s", raw)
}
return raw, nil
}
host := parsed.Hostname()
port := parsed.Port()
if port == "" {
switch parsed.Scheme {
case "http":
port = "80"
case "https":
port = "443"
case "ssh":
port = "22"
default:
return "", fmt.Errorf("service 缺少端口:%s", raw)
}
}
return net.JoinHostPort(host, port), nil
}
+28
View File
@@ -0,0 +1,28 @@
package cloudflared
import "testing"
func TestCompareIngressDetectsMissingAndMismatch(t *testing.T) {
expected := BuildIngress([]string{"a.example.com", "b.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Hostname: "a.example.com", Service: "http://127.0.0.1:9090"},
{Hostname: "c.example.com", Service: "http://127.0.0.1:8080"},
{Service: "http_status:404"},
}
items := compareIngress(expected, actual)
if len(items) != 3 {
t.Fatalf("应检测出 3 项漂移,实际 %#v", items)
}
}
func TestCompareIngressIgnoresOrder(t *testing.T) {
expected := BuildIngress([]string{"b.example.com", "a.example.com"}, "http://127.0.0.1:8080")
actual := []IngressRule{
{Service: "http_status:404"},
{Hostname: "a.example.com", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "http://127.0.0.1:8080"},
}
if items := compareIngress(expected, actual); len(items) != 0 {
t.Fatalf("顺序不同不应算漂移:%#v", items)
}
}
+185
View File
@@ -0,0 +1,185 @@
package cloudflared
import (
"context"
"fmt"
"os"
"strings"
"github.com/fonu/fonu/internal/fsutil"
"github.com/fonu/fonu/internal/validate"
)
// RouteView 是配置页展示的一条 ingress 路由。
type RouteView struct {
ID int64 `json:"id,omitempty"`
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
Source string `json:"source"`
}
// TunnelRoutes 是配置页需要的路由列表。
type TunnelRoutes struct {
Managed bool `json:"managed"`
Routes []RouteView `json:"routes"`
CatchAll string `json:"catch_all"`
}
// RouteInput 是手工隧道路由保存入参。
type RouteInput struct {
Hostname string `json:"hostname"`
Path string `json:"path"`
Service string `json:"service"`
}
// Routes 返回托管规则的派生路由,或手工 config.yml 中的 ingress 路由。
func (s *Service) Routes(ctx context.Context, id int64) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return s.managedRoutes(ctx, tunnel)
}
return manualRoutes(tunnel.ConfigPath)
}
func (s *Service) managedRoutes(ctx context.Context, tunnel Tunnel) (TunnelRoutes, error) {
if s.rules == nil {
return TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return TunnelRoutes{}, err
}
out := TunnelRoutes{Managed: true, Routes: []RouteView{}, CatchAll: "http_status:404"}
for _, rule := range rulesForTunnel(rules, tunnel.ID) {
for _, host := range rule.Hostnames() {
out.Routes = append(out.Routes, RouteView{
ID: rule.ID, Hostname: host, Path: "*", Service: rule.Upstream, Source: "rule",
})
}
}
return out, nil
}
func manualRoutes(configPath string) (TunnelRoutes, error) {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}, nil
}
return TunnelRoutes{}, err
}
out := TunnelRoutes{Routes: []RouteView{}, CatchAll: "http_status:404"}
lines := strings.Split(string(data), "\n")
for i, raw := range lines {
line := strings.TrimSpace(raw)
host, found := strings.CutPrefix(line, "- hostname:")
if !found {
continue
}
hostname := strings.TrimSpace(host)
if hostname == "" || i+1 >= len(lines) {
continue
}
path := "*"
next := i + 1
if value, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "path:"); ok {
path = strings.TrimSpace(value)
next++
}
if next >= len(lines) {
continue
}
service, ok := strings.CutPrefix(strings.TrimSpace(lines[next]), "service:")
if !ok {
continue
}
out.Routes = append(out.Routes, RouteView{
Hostname: hostname, Path: path, Service: strings.TrimSpace(service), Source: "config",
})
}
if len(out.Routes) > 0 {
catchAll, err := catchAllService(data)
if err == nil && catchAll != "" {
out.CatchAll = catchAll
}
}
return out, nil
}
func catchAllService(data []byte) (string, error) {
lines := strings.Split(string(data), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "- service:") {
return strings.TrimSpace(strings.TrimPrefix(line, "- service:")), nil
}
}
return "", fmt.Errorf("未找到 catch-all")
}
// ReplaceRoutes 保存手工隧道的路由列表,并重新生成本地与云端 ingress。
func (s *Service) ReplaceRoutes(ctx context.Context, id int64, routes []RouteInput) (TunnelRoutes, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return TunnelRoutes{}, err
}
if tunnel.Managed {
return TunnelRoutes{}, fmt.Errorf("托管隧道的路由由反向代理规则管理")
}
ingress, hostnames, err := buildManualIngress(routes)
if err != nil {
return TunnelRoutes{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)), 0o640); err != nil {
return TunnelRoutes{}, err
}
if tunnel.Mode == ModeAccountLocal && tunnel.TunnelID != "" {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return TunnelRoutes{}, err
}
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
return TunnelRoutes{}, err
}
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnames); err != nil {
return TunnelRoutes{}, err
}
}
if err := s.restartIfRunning(ctx, tunnel.ID); err != nil {
return TunnelRoutes{}, err
}
return s.Routes(ctx, id)
}
func buildManualIngress(routes []RouteInput) ([]IngressRule, []string, error) {
seen := map[string]struct{}{}
ingress := make([]IngressRule, 0, len(routes)+1)
hostnames := make([]string, 0, len(routes))
for _, route := range routes {
hostname := strings.ToLower(strings.TrimSpace(route.Hostname))
if err := validate.Domain(hostname); err != nil {
return nil, nil, fmt.Errorf("域名 %s 无效:%w", hostname, err)
}
if _, ok := seen[hostname]; ok {
return nil, nil, fmt.Errorf("域名 %s 重复", hostname)
}
seen[hostname] = struct{}{}
service := strings.TrimSpace(route.Service)
if service == "" || !strings.Contains(service, "://") {
return nil, nil, fmt.Errorf("域名 %s 的源服务格式无效", hostname)
}
path := strings.TrimSpace(route.Path)
// Cloudflare ingress 的 path 是正则;通配路由必须省略 path,发送 "*" 会被判为非法正则。
if path == "" || path == "*" {
path = ""
}
ingress = append(ingress, IngressRule{Hostname: hostname, Path: path, Service: service})
hostnames = append(hostnames, hostname)
}
ingress = append(ingress, IngressRule{Service: "http_status:404"})
return ingress, hostnames, nil
}
+51
View File
@@ -0,0 +1,51 @@
package cloudflared
import (
"os"
"path/filepath"
"testing"
)
func TestManualRoutesFromConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml")
content := `tunnel: test
ingress:
- hostname: a.example.com
service: http://127.0.0.1:8080
- hostname: b.example.com
service: http://127.0.0.1:9090
- service: http_status:404
`
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatal(err)
}
result, err := manualRoutes(path)
if err != nil {
t.Fatal(err)
}
if len(result.Routes) != 2 || result.Routes[0].Hostname != "a.example.com" || result.Routes[1].Service != "http://127.0.0.1:9090" {
t.Fatalf("手工路由解析不符:%#v", result)
}
if result.CatchAll != "http_status:404" {
t.Fatalf("catch-all 不符:%q", result.CatchAll)
}
}
func TestBuildManualIngress(t *testing.T) {
ingress, hostnames, err := buildManualIngress([]RouteInput{
{Hostname: "A.Example.com", Path: "*", Service: "http://127.0.0.1:8080"},
{Hostname: "b.example.com", Service: "https://127.0.0.1:8443"},
})
if err != nil {
t.Fatal(err)
}
if len(ingress) != 3 || ingress[0].Hostname != "a.example.com" || ingress[1].Hostname != "b.example.com" {
t.Fatalf("ingress 结果不符:%#v", ingress)
}
if ingress[0].Path != "" || ingress[1].Path != "" {
t.Fatalf("通配路径不应写入 Cloudflare ingress:%#v", ingress)
}
if len(hostnames) != 2 || hostnames[1] != "b.example.com" {
t.Fatalf("hostnames 结果不符:%#v", hostnames)
}
}
+320 -19
View File
@@ -102,6 +102,9 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
if in.Managed {
ingress = BuildIngress(nil, origin)
}
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
@@ -120,8 +123,15 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
_ = api.DeleteTunnel(ctx, tunnelID)
return Tunnel{}, err
}
if tunnel.Managed {
if err := s.syncManagedTunnel(ctx, tunnel); err != nil {
s.logger.Warn("创建托管隧道后同步规则失败", "tunnel_id", decodedID, "error", err.Error())
tunnel.DNSWarning = "隧道已创建,但规则或 DNS 同步失败:" + err.Error()
}
return tunnel, nil
}
_ = api.PutIngress(ctx, decodedID, ingress)
if err := s.ensureTunnelDNS(ctx, api, decodedID, in.Hostnames); err != nil {
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, decodedID, in.Hostnames); err != nil {
s.logger.Warn("自动创建 Cloudflare DNS 失败", "tunnel_id", decodedID, "error", err.Error())
tunnel.DNSWarning = "隧道已创建,但 DNS 记录未自动创建:" + err.Error()
}
@@ -134,6 +144,11 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel,
if err != nil {
return Tunnel{}, err
}
if current.Managed && !in.Managed {
if count := s.tunnelUsageCount(ctx, id); count > 0 {
return Tunnel{}, fmt.Errorf("仍有 %d 条反向代理规则使用该托管隧道,请先移除 Cloudflare 出口", count)
}
}
_ = s.manager.Stop(id)
in.Network = s.applyDefaultNetwork(ctx, in.Network)
origin := strings.TrimSpace(in.OriginService)
@@ -141,24 +156,31 @@ func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel,
origin = defaultOriginService
}
ingress := BuildIngress(in.Hostnames, origin)
if in.Managed {
ingress = BuildIngress(nil, origin)
}
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(BuildConfig(current.TunnelID, current.CredsPath, in.Network, ingress)), 0o640); err != nil {
return Tunnel{}, err
}
dnsWarning := ""
if current.Mode == ModeAccountLocal && current.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
_ = api.PutIngress(ctx, current.TunnelID, ingress)
if err := s.ensureTunnelDNS(ctx, api, current.TunnelID, in.Hostnames); err != nil {
s.logger.Warn("同步 Cloudflare DNS 失败", "tunnel_id", current.TunnelID, "error", err.Error())
dnsWarning = "DNS 记录同步失败:" + err.Error()
}
}
}
updated, err := s.store.Update(ctx, id, in, current.TunnelID, current.AccountTag, current.CredentialsEnc, current.CertPEMEnc,
current.ConfigPath, current.CredsPath, current.LogPath)
if err != nil {
return Tunnel{}, err
}
dnsWarning := ""
if updated.Managed {
if err := s.syncManagedTunnel(ctx, updated); err != nil {
dnsWarning = "托管隧道已保存,但规则或 DNS 同步失败:" + err.Error()
}
} else if updated.Mode == ModeAccountLocal && updated.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
_ = api.PutIngress(ctx, updated.TunnelID, ingress)
if err := s.syncTunnelDNS(ctx, api, updated.ID, updated.TunnelID, in.Hostnames); err != nil {
s.logger.Warn("同步 Cloudflare DNS 失败", "tunnel_id", updated.TunnelID, "error", err.Error())
dnsWarning = "DNS 记录同步失败:" + err.Error()
}
}
}
updated.DNSWarning = dnsWarning
return updated, nil
}
@@ -168,9 +190,13 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
if err != nil {
return err
}
if count := s.tunnelUsageCount(ctx, id); count > 0 {
return fmt.Errorf("仍有 %d 条反向代理规则使用该隧道,请先移除这些规则的 Cloudflare 出口", count)
}
_ = s.manager.Stop(id)
if current.TunnelID != "" {
if api, err := s.cloudflareAPI(ctx); err == nil {
s.deleteTunnelDNS(ctx, api, current.ID)
_ = api.DeleteTunnel(ctx, current.TunnelID)
}
}
@@ -180,6 +206,23 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
return s.store.Delete(ctx, id)
}
func (s *Service) tunnelUsageCount(ctx context.Context, id int64) int {
if s.rules == nil {
return 0
}
rules, err := s.rules.List(ctx)
if err != nil {
return 0
}
count := 0
for _, rule := range rules {
if rule.UsesExit(proxy.ExitCloudflare) && rule.CFTunnelID == id {
count++
}
}
return count
}
func (s *Service) Start(ctx context.Context, id int64) (Tunnel, error) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
@@ -223,6 +266,40 @@ func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
return s.Start(ctx, id)
}
// RefreshCredentials 重新从 Cloudflare 获取隧道凭据并更新本地 creds.json。
func (s *Service) RefreshCredentials(ctx context.Context, id int64) (Tunnel, error) {
tunnel, err := s.store.Get(ctx, id)
if err != nil {
return Tunnel{}, err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return Tunnel{}, err
}
token, err := api.TunnelToken(ctx, tunnel.TunnelID)
if err != nil {
return Tunnel{}, err
}
accountTag, decodedID, tunnelSecret, err := DecodeToken(token)
if err != nil {
return Tunnel{}, err
}
if err := fsutil.WriteFileAtomic(tunnel.CredsPath, []byte(CredentialsJSON(accountTag, decodedID, tunnelSecret)), 0o600); err != nil {
return Tunnel{}, err
}
encrypted, err := s.secretBox.Encrypt(token)
if err != nil {
return Tunnel{}, err
}
if err := s.store.UpdateCredentials(ctx, id, encrypted); err != nil {
return Tunnel{}, err
}
if s.manager.Status(id).Running {
return s.Restart(ctx, id)
}
return s.Get(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
@@ -301,7 +378,7 @@ func (s *Service) SetEnabled(ctx context.Context, enabled bool) (AppSettings, er
return AppSettings{}, err
}
if enabled {
s.StartAuto(ctx)
s.StartAll(ctx)
} else {
s.StopAll(ctx)
}
@@ -353,15 +430,131 @@ func (s *Service) cloudflareAPI(ctx context.Context) (*CloudflareAPI, error) {
return NewCloudflareAPI(accountID, token), nil
}
// ensureTunnelDNS 为所有 hostname 幂等创建或更新指向隧道的 CNAME 记录。
func (s *Service) ensureTunnelDNS(ctx context.Context, api *CloudflareAPI, tunnelID string, hostnames []string) error {
target := tunnelID + ".cfargotunnel.com"
// SyncRules 让所有托管隧道从反向代理规则重新派生 ingress 与 DNS。
func (s *Service) SyncRules(ctx context.Context) error {
if s.rules == nil {
return nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return err
}
tunnels, err := s.store.List(ctx)
if err != nil {
return err
}
var firstErr error
for _, raw := range hostnames {
hostname := strings.ToLower(strings.TrimSpace(raw))
if hostname == "" {
for _, tunnel := range tunnels {
if !tunnel.Managed {
continue
}
assigned := rulesForTunnel(rules, tunnel.ID)
dnsRecords, err := s.store.ListDNSRecords(ctx, tunnel.ID)
if err != nil && firstErr == nil {
firstErr = err
continue
}
if len(assigned) == 0 && len(dnsRecords) == 0 {
continue
}
if err := s.syncManagedTunnel(ctx, tunnel); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func rulesForTunnel(rules []proxy.Rule, tunnelID int64) []proxy.Rule {
out := make([]proxy.Rule, 0)
for _, rule := range rules {
if !rule.CloudflareEnabled() || rule.CFTunnelID != tunnelID {
continue
}
out = append(out, rule)
}
return out
}
func (s *Service) syncManagedTunnel(ctx context.Context, tunnel Tunnel) error {
if !tunnel.Managed {
return nil
}
if s.rules == nil {
return nil
}
rules, err := s.rules.List(ctx)
if err != nil {
return err
}
assigned := rulesForTunnel(rules, tunnel.ID)
ingress := BuildIngressForRules(assigned)
content := BuildConfig(tunnel.TunnelID, tunnel.CredsPath, tunnel.Network, ingress)
if err := fsutil.WriteFileAtomic(tunnel.ConfigPath, []byte(content), 0o640); err != nil {
return err
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return err
}
if err := api.PutIngress(ctx, tunnel.TunnelID, ingress); err != nil {
return err
}
if err := s.syncTunnelDNS(ctx, api, tunnel.ID, tunnel.TunnelID, hostnamesFromIngress(ingress)); err != nil {
return err
}
return s.restartIfRunning(ctx, tunnel.ID)
}
func hostnamesFromIngress(ingress []IngressRule) []string {
out := make([]string, 0, len(ingress))
for _, item := range ingress {
if strings.TrimSpace(item.Hostname) != "" {
out = append(out, item.Hostname)
}
}
return out
}
func (s *Service) restartIfRunning(ctx context.Context, id int64) error {
if !s.manager.Status(id).Running {
return nil
}
if _, err := s.Restart(ctx, id); err != nil {
return fmt.Errorf("配置已同步,但隧道重启失败:%w", err)
}
return nil
}
// syncTunnelDNS 让 Cloudflare DNS 与目标 hostname 保持一致,并只清理 Fonu 自己接管的记录。
func (s *Service) syncTunnelDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64, remoteTunnelID string, hostnames []string) error {
desired := make(map[string]struct{}, len(hostnames))
for _, raw := range hostnames {
hostname := strings.ToLower(strings.TrimSpace(raw))
if hostname != "" {
desired[hostname] = struct{}{}
}
}
records, err := s.store.ListDNSRecords(ctx, localTunnelID)
if err != nil {
return err
}
var firstErr error
for _, record := range records {
if _, ok := desired[record.Hostname]; ok {
continue
}
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil && firstErr == nil {
firstErr = err
}
}
target := remoteTunnelID + ".cfargotunnel.com"
for hostname := range desired {
zoneID, _, err := api.FindZone(ctx, hostname)
if err != nil {
if firstErr == nil {
@@ -369,13 +562,71 @@ func (s *Service) ensureTunnelDNS(ctx context.Context, api *CloudflareAPI, tunne
}
continue
}
if err := api.EnsureCNAME(ctx, zoneID, hostname, target); err != nil && firstErr == nil {
recordID, err := api.EnsureCNAME(ctx, zoneID, hostname, target)
if err != nil {
if transferredID, transferErr := s.transferTrackedDNS(ctx, api, localTunnelID, zoneID, hostname, target); transferErr == nil {
recordID = transferredID
err = nil
}
}
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if strings.TrimSpace(recordID) == "" {
if firstErr == nil {
firstErr = fmt.Errorf("Cloudflare 未返回 %s 的 DNS 记录 ID", hostname)
}
continue
}
if err := s.store.UpsertDNSRecord(ctx, localTunnelID, zoneID, recordID, hostname); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
func (s *Service) transferTrackedDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64, zoneID, hostname, target string) (string, error) {
record, err := s.store.FindDNSRecord(ctx, hostname)
if err != nil {
return "", err
}
if record.TunnelID == localTunnelID {
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
return "", err
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil {
return "", err
}
return api.EnsureCNAME(ctx, zoneID, hostname, target)
}
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
return "", err
}
if err := s.store.DeleteDNSRecord(ctx, record.ID); err != nil {
return "", err
}
return api.EnsureCNAME(ctx, zoneID, hostname, target)
}
func (s *Service) deleteTunnelDNS(ctx context.Context, api *CloudflareAPI, localTunnelID int64) {
records, err := s.store.ListDNSRecords(ctx, localTunnelID)
if err != nil {
s.logger.Warn("读取 Cloudflare DNS 记录失败", "tunnel_id", localTunnelID, "error", err.Error())
return
}
for _, record := range records {
if err := api.DeleteDNSRecord(ctx, record.ZoneID, record.RecordID); err != nil {
s.logger.Warn("删除 Cloudflare DNS 记录失败", "hostname", record.Hostname, "error", err.Error())
continue
}
_ = s.store.DeleteDNSRecord(ctx, record.ID)
}
_ = s.store.DeleteDNSRecordsForTunnel(ctx, localTunnelID)
}
// VerifyAPIToken 用 Account ID + API Token 调用 Cloudflare 账号级 tokens/verify 接口。
// accountID / input 为空时回退到应用配置中已保存的值。
func (s *Service) VerifyAPIToken(ctx context.Context, accountID, input string) (string, error) {
@@ -408,6 +659,28 @@ func (s *Service) TestToken(ctx context.Context, accountID, input, _ string) (st
return s.VerifyAPIToken(ctx, accountID, input)
}
// Zones 返回当前 API Token 可访问的 Cloudflare Zone。
func (s *Service) Zones(ctx context.Context) ([]Zone, error) {
api, err := s.cloudflareAPI(ctx)
if err != nil {
return nil, err
}
return api.ListZones(ctx)
}
// AccessStatus 只读检测域名是否启用了 Cloudflare Access。
func (s *Service) AccessStatus(ctx context.Context, hostname string) (bool, string, error) {
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" {
return false, "", fmt.Errorf("域名为空")
}
api, err := s.cloudflareAPI(ctx)
if err != nil {
return false, "", err
}
return api.HasAccessForHostname(ctx, hostname)
}
// attachConfig 从 config.yml 回填域名与回源 service,供编辑弹窗使用。
func (s *Service) attachConfig(item *Tunnel) {
data, err := os.ReadFile(item.ConfigPath)
@@ -504,6 +777,28 @@ func (s *Service) StartAuto(ctx context.Context) {
}
}
// StartAll 启动全部隧道;用于全局总开关重新开启时立即恢复所有出口。
func (s *Service) StartAll(ctx context.Context) {
settings, err := s.store.GetSettingsRaw(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 配置失败", "error", err.Error())
return
}
if !settings.Enabled {
return
}
tunnels, err := s.store.List(ctx)
if err != nil {
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
return
}
for _, tunnel := range tunnels {
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 == '.' {
@@ -523,5 +818,11 @@ func validateInput(in CreateInput) error {
default:
return fmt.Errorf("不支持的隧道模式: %s", in.Mode)
}
if !in.Managed && len(in.Hostnames) == 0 {
return fmt.Errorf("手动隧道至少需要一个暴露域名")
}
if in.Managed && in.Mode != ModeAccountLocal {
return fmt.Errorf("仅 API 管理隧道支持自动托管")
}
return nil
}
+26
View File
@@ -74,3 +74,29 @@ func TestSetEnabledBlocksStart(t *testing.T) {
t.Fatalf("重新启用失败:%v %#v", err, settings)
}
}
func TestManagedTunnelPersists(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatal(err)
}
store := NewStore(conn)
item, err := store.Create(context.Background(), CreateInput{
Name: "managed",
Mode: ModeAccountLocal,
Network: DefaultNetworkSettings(),
Managed: true,
AutoStart: true,
}, "remote-tunnel", "account", "encrypted", "", "/tmp/config.yml", "/tmp/creds.json", "/tmp/cloudflared.log")
if err != nil {
t.Fatal(err)
}
if !item.Managed || !item.AutoStart || item.TunnelID != "remote-tunnel" {
t.Fatalf("托管隧道字段未持久化:%#v", item)
}
}
+84 -8
View File
@@ -23,7 +23,7 @@ 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
COALESCE(metrics_port, 0), COALESCE(auto_start, 0), COALESCE(managed, 0), created_at, updated_at
FROM cf_tunnels`
func (s *Store) List(ctx context.Context) ([]Tunnel, error) {
@@ -55,10 +55,10 @@ func (s *Store) Create(ctx context.Context, in CreateInput, tunnelID, accountTag
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'))
config_path, creds_path, log_path, network_json, status, auto_start, managed, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, ?, datetime('now'))
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart))
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed))
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
@@ -72,10 +72,10 @@ func (s *Store) Update(ctx context.Context, id int64, in CreateInput, tunnelID,
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')
network_json = ?, auto_start = ?, managed = ?, updated_at = datetime('now')
WHERE id = ?
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), id)
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), boolInt(in.Managed), id)
if err != nil {
return Tunnel{}, mapUniqueName(err)
}
@@ -95,6 +95,13 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri
return err
}
func (s *Store) UpdateCredentials(ctx context.Context, id int64, credentialsEnc string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE cf_tunnels SET credentials_enc = ?, updated_at = datetime('now') WHERE id = ?
`, credentialsEnc, id)
return err
}
// settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。
type settingsRow struct {
AccountID string
@@ -105,6 +112,74 @@ type settingsRow struct {
Network NetworkSettings
}
// DNSRecord 是 Fonu 自动创建或接管的 Cloudflare DNS 记录。
type DNSRecord struct {
ID int64 `json:"id"`
TunnelID int64 `json:"tunnel_id"`
ZoneID string `json:"zone_id"`
RecordID string `json:"record_id"`
Hostname string `json:"hostname"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (s *Store) ListDNSRecords(ctx context.Context, tunnelID int64) ([]DNSRecord, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, tunnel_id, zone_id, record_id, hostname, created_at, updated_at
FROM cf_dns_records WHERE tunnel_id = ? ORDER BY hostname ASC
`, tunnelID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DNSRecord{}
for rows.Next() {
var item DNSRecord
if err := rows.Scan(&item.ID, &item.TunnelID, &item.ZoneID, &item.RecordID, &item.Hostname, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *Store) FindDNSRecord(ctx context.Context, hostname string) (DNSRecord, error) {
var item DNSRecord
err := s.db.QueryRowContext(ctx, `
SELECT id, tunnel_id, zone_id, record_id, hostname, created_at, updated_at
FROM cf_dns_records WHERE hostname = ?
`, strings.ToLower(strings.TrimSpace(hostname))).Scan(
&item.ID, &item.TunnelID, &item.ZoneID, &item.RecordID, &item.Hostname, &item.CreatedAt, &item.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return DNSRecord{}, fmt.Errorf("Cloudflare DNS 记录不存在")
}
return item, err
}
func (s *Store) UpsertDNSRecord(ctx context.Context, tunnelID int64, zoneID, recordID, hostname string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO cf_dns_records(tunnel_id, zone_id, record_id, hostname, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(hostname) DO UPDATE SET
tunnel_id = excluded.tunnel_id,
zone_id = excluded.zone_id,
record_id = excluded.record_id,
updated_at = datetime('now')
`, tunnelID, zoneID, recordID, strings.ToLower(strings.TrimSpace(hostname)))
return err
}
func (s *Store) DeleteDNSRecord(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_dns_records WHERE id = ?`, id)
return err
}
func (s *Store) DeleteDNSRecordsForTunnel(ctx context.Context, tunnelID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_dns_records WHERE tunnel_id = ?`, tunnelID)
return err
}
func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) {
var accountID, tokenEnc, apiTokenEnc, mirror, networkJSON string
var enabled int
@@ -137,14 +212,15 @@ func (s *Store) SaveSettings(ctx context.Context, accountID string, enabled bool
func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) {
var item Tunnel
var networkJSON, created, updated string
var autoStart int
var autoStart, managed 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)
&networkJSON, &item.Status, &item.LastError, &item.MetricsPort, &autoStart, &managed, &created, &updated)
if err != nil {
return Tunnel{}, err
}
item.AutoStart = autoStart == 1
item.Managed = managed == 1
item.Network = DefaultNetworkSettings()
_ = json.Unmarshal([]byte(networkJSON), &item.Network)
item.CreatedAt = parseTime(created)
+4
View File
@@ -38,6 +38,10 @@ var catalog = []catalogEntry{
{Name: "PhotoPrism", Port: 2342, Platform: "photoprism", Keywords: []string{"photoprism"}},
{Name: "qBittorrent", Port: 8080, Platform: "qbittorrent", Keywords: []string{"qbittorrent"}},
{Name: "Portainer", Port: 9000, Platform: "portainer", Keywords: []string{"portainer"}},
{Name: "Home Assistant", Port: 8123, Platform: "home-assistant", Keywords: []string{"home assistant", "homeassistant"}},
{Name: "Immich", Port: 2283, Platform: "immich", Keywords: []string{"immich"}},
{Name: "AdGuard Home", Port: 3000, Platform: "adguard-home", Keywords: []string{"adguard home", "adguardhome"}},
{Name: "Synology DSM", Port: 5000, Platform: "synology", Keywords: []string{"synology", "diskstation"}},
}
func New() *Service {
+120 -8
View File
@@ -57,6 +57,38 @@ type HealthRecorder interface {
PruneRouteHealth(ctx context.Context) error
}
// CloudflareRouteStatus 是 Cloudflare 隧道域名的巡检快照。
type CloudflareRouteStatus struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
Known bool
}
// CloudflareHealthSample 是 Cloudflare 隧道的状态变更样本。
type CloudflareHealthSample struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
}
// CloudflareHealthSource 提供 Cloudflare 隧道健康快照。
type CloudflareHealthSource interface {
CloudflareRoutes(ctx context.Context) ([]CloudflareRouteStatus, error)
}
// CloudflareHealthRecorder 持久化 Cloudflare 隧道健康状态。
type CloudflareHealthRecorder interface {
RecordCloudflareHealth(ctx context.Context, samples []CloudflareHealthSample) error
PruneCloudflareHealth(ctx context.Context) error
}
// ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。
type ServerStatus struct {
ID int64
@@ -113,14 +145,15 @@ type Checker struct {
health HealthRecorder
metrics MetricsRecorder
mu sync.Mutex
cpuPrev sysinfo.CPUSample
resourceAlerts map[string]time.Time
failures map[string]int
driftAlerted map[int64]bool
driftNotified map[int64]time.Time
lastHealthPrune time.Time
lastMetricsPrune time.Time
mu sync.Mutex
cpuPrev sysinfo.CPUSample
resourceAlerts map[string]time.Time
failures map[string]int
driftAlerted map[int64]bool
driftNotified map[int64]time.Time
lastHealthPrune time.Time
lastCFHealthPrune time.Time
lastMetricsPrune time.Time
}
func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, source Source, health HealthRecorder, metricsRecorder MetricsRecorder) *Checker {
@@ -151,6 +184,7 @@ func (c *Checker) Run(ctx context.Context) {
c.checkServers(ctx)
c.checkAgents(ctx)
c.checkDrift(ctx)
c.checkCloudflare(ctx)
}
// runHostChecks 一次采样同时喂给「资源阈值告警」与「指标历史」。
@@ -343,6 +377,84 @@ func (c *Checker) checkRoutes(ctx context.Context) {
}
}
// checkCloudflare 把 Cloudflare 隧道纳入现有巡检、通知与健康记录。
func (c *Checker) checkCloudflare(ctx context.Context) {
source, ok := c.source.(CloudflareHealthSource)
if !ok {
return
}
recordHealth, _ := c.settings.GetBool(ctx, settings.KeyRouteHealthEnabled)
recorder, canRecord := c.source.(CloudflareHealthRecorder)
if !canRecord {
recordHealth = false
}
alertEnabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown)
if !recordHealth && !alertEnabled {
return
}
routes, err := source.CloudflareRoutes(ctx)
if err != nil {
c.logger.Warn("巡检 Cloudflare 隧道状态失败", "module", "MONITOR", "error", err.Error())
return
}
if recordHealth {
samples := make([]CloudflareHealthSample, 0, len(routes))
for _, route := range routes {
if strings.TrimSpace(route.Domain) == "" {
continue
}
samples = append(samples, CloudflareHealthSample{
TunnelID: route.TunnelID,
Name: route.Name,
Domain: route.Domain,
State: route.State,
Reason: route.Reason,
Detail: route.Detail,
})
}
if err := recorder.RecordCloudflareHealth(ctx, samples); err != nil {
c.logger.Warn("记录 Cloudflare 隧道健康失败", "module", "MONITOR", "error", err.Error())
}
c.pruneCloudflareHealthIfDue(ctx, recorder)
}
if !alertEnabled {
return
}
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
if limit <= 0 {
return
}
for _, route := range routes {
if !route.Known || strings.TrimSpace(route.Domain) == "" {
continue
}
_, event := c.track(fmt.Sprintf("cloudflare:%d:%s", route.TunnelID, route.Domain), route.State == HealthUp, limit)
switch event {
case failureEventDown:
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道连续不通",
fmt.Sprintf("%s 连续 %d 次巡检未连接:%s", route.Domain, limit, detailOr(route.Detail)))
case failureEventRecovered:
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道已恢复",
fmt.Sprintf("%s 已重新连接 Cloudflare 边缘", route.Domain))
}
}
}
func (c *Checker) pruneCloudflareHealthIfDue(ctx context.Context, recorder CloudflareHealthRecorder) {
c.mu.Lock()
due := c.lastCFHealthPrune.IsZero() || time.Since(c.lastCFHealthPrune) >= 24*time.Hour
if due {
c.lastCFHealthPrune = time.Now()
}
c.mu.Unlock()
if !due {
return
}
if err := recorder.PruneCloudflareHealth(ctx); err != nil {
c.logger.Warn("清理 Cloudflare 隧道健康记录失败", "module", "MONITOR", "error", err.Error())
}
}
// healthStateOf 把一段状态翻成与 frp 侧一致的结论 + 失败环节
func healthStateOf(route RouteStatus) (string, string) {
if !route.Known {
+55
View File
@@ -0,0 +1,55 @@
package proxy
import (
"context"
"testing"
)
func TestStorePersistsExitsAndFiltersLocal(t *testing.T) {
store, _ := newTestStore(t)
ctx := context.Background()
rule, err := store.Create(ctx, CreateInput{
Upstream: "http://192.168.1.10:8080",
ListenPort: 8443,
ListenIPv4: true,
Hosts: []string{"cf.example.com"},
Enabled: true,
Exits: []string{ExitCloudflare},
CFTunnelID: 7,
})
if err != nil {
t.Fatal(err)
}
if rule.CFTunnelID != 7 || len(rule.Exits) != 1 || rule.Exits[0] != ExitCloudflare {
t.Fatalf("Cloudflare 出口未持久化:%#v", rule)
}
localRules, err := store.ListEnabledLocal(ctx)
if err != nil {
t.Fatal(err)
}
if len(localRules) != 0 {
t.Fatalf("仅 Cloudflare 规则不应生成本机 Nginx:%#v", localRules)
}
enabled := true
updated, err := store.Update(ctx, rule.ID, UpdateInput{
Exits: &[]string{ExitLocal, ExitCloudflare},
Enabled: &enabled,
CFTunnelID: int64Ptr(7),
})
if err != nil {
t.Fatal(err)
}
if len(updated.Exits) != 2 {
t.Fatalf("本机 + Cloudflare 出口未保存:%#v", updated.Exits)
}
localRules, err = store.ListEnabledLocal(ctx)
if err != nil {
t.Fatal(err)
}
if len(localRules) != 1 || localRules[0].ID != rule.ID {
t.Fatalf("启用本机出口后应进入 Nginx 规则:%#v", localRules)
}
}
func int64Ptr(v int64) *int64 { return &v }
+39
View File
@@ -0,0 +1,39 @@
package proxy
import "testing"
func TestNormalizeExitsDefaultsToLocal(t *testing.T) {
exits, tunnelID, err := normalizeExits(nil, 0)
if err != nil {
t.Fatal(err)
}
if tunnelID != 0 || len(exits) != 1 || exits[0] != ExitLocal {
t.Fatalf("默认出口应为 local:%#v tunnel=%d", exits, tunnelID)
}
}
func TestNormalizeExitsRequiresTunnelForCloudflare(t *testing.T) {
if _, _, err := normalizeExits([]string{ExitCloudflare}, 0); err == nil {
t.Fatal("Cloudflare 出口缺少隧道时应报错")
}
}
func TestNormalizeExitsDeduplicatesAndSorts(t *testing.T) {
exits, tunnelID, err := normalizeExits([]string{ExitCloudflare, ExitLocal, ExitCloudflare}, 12)
if err != nil {
t.Fatal(err)
}
if tunnelID != 12 || len(exits) != 2 || exits[0] != ExitCloudflare || exits[1] != ExitLocal {
t.Fatalf("出口去重排序不符:%#v tunnel=%d", exits, tunnelID)
}
}
func TestRuleUsesExitTreatsEmptyAsLocal(t *testing.T) {
rule := Rule{}
if !rule.UsesExit(ExitLocal) {
t.Fatal("空出口应兼容为本机出口")
}
if rule.UsesExit(ExitCloudflare) {
t.Fatal("空出口不应被视为 Cloudflare")
}
}
+153 -8
View File
@@ -3,14 +3,21 @@ package proxy
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/fonu/fonu/internal/validate"
)
const (
ExitLocal = "local"
ExitCloudflare = "cloudflare"
)
type Host struct {
ID int64 `json:"id"`
Hostname string `json:"hostname"`
@@ -32,6 +39,9 @@ type Rule struct {
Name string `json:"name"`
SortOrder int `json:"sort_order"`
Security SecurityConfig `json:"security"`
Exits []string `json:"exits"`
CFTunnelID int64 `json:"cf_tunnel_id"`
SyncWarning string `json:"sync_warning,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -98,6 +108,34 @@ func (r Rule) Hostnames() []string {
return names
}
// UsesExit 判断规则是否启用了指定出口;空值按旧数据兼容为本机出口。
func (r Rule) UsesExit(exit string) bool {
if len(r.Exits) == 0 {
return exit == ExitLocal
}
for _, item := range r.Exits {
if item == exit {
return true
}
}
return false
}
// HasExit 判断出口列表是否包含指定出口;空值按旧数据兼容为本机出口。
func HasExit(exits []string, exit string) bool {
return Rule{Exits: exits}.UsesExit(exit)
}
// LocalEnabled 判断规则当前是否需要生成本机 Nginx 配置。
func (r Rule) LocalEnabled() bool {
return r.Enabled && r.UsesExit(ExitLocal)
}
// CloudflareEnabled 判断规则当前是否需要同步到 Cloudflare 隧道。
func (r Rule) CloudflareEnabled() bool {
return r.Enabled && r.UsesExit(ExitCloudflare) && r.CFTunnelID > 0
}
func (r Rule) PortGroups() []PortGroup {
groups := map[int][]string{}
ports := make([]int, 0)
@@ -130,6 +168,8 @@ type CreateInput struct {
Enabled bool
Name string
Security SecurityInput
Exits []string
CFTunnelID int64
}
type UpdateInput struct {
@@ -143,6 +183,8 @@ type UpdateInput struct {
Enabled *bool
Name *string
Security *SecurityInput
Exits *[]string
CFTunnelID *int64
}
type Store struct {
@@ -175,7 +217,7 @@ func (s *Store) List(ctx context.Context) ([]Rule, error) {
}
q := s.querier()
rows, err := q.QueryContext(ctx, `
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
FROM proxy_rules
ORDER BY sort_order ASC, id ASC
`)
@@ -200,7 +242,7 @@ func (s *Store) List(ctx context.Context) ([]Rule, error) {
func (s *Store) Get(ctx context.Context, id int64) (Rule, error) {
row := s.querier().QueryRowContext(ctx, `
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
FROM proxy_rules WHERE id = ?
`, id)
rule, err := scanRule(row)
@@ -243,11 +285,19 @@ func (s *Store) Create(ctx context.Context, in CreateInput) (Rule, error) {
if err != nil {
return Rule{}, err
}
exits, cfTunnelID, err := normalizeExits(in.Exits, in.CFTunnelID)
if err != nil {
return Rule{}, err
}
exitsJSON, err := marshalExits(exits)
if err != nil {
return Rule{}, err
}
res, err := s.querier().ExecContext(ctx, `
INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON)
INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, exits_json, cf_tunnel_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON, exitsJSON, cfTunnelID)
if err != nil {
return Rule{}, err
}
@@ -278,6 +328,8 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
enabled := current.Enabled
name := current.Name
hosts := current.Hosts
exits := current.Exits
cfTunnelID := current.CFTunnelID
if in.Upstream != nil {
upstream, err = validate.Upstream(*in.Upstream)
@@ -315,6 +367,20 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
return Rule{}, err
}
}
if in.Exits != nil || in.CFTunnelID != nil {
rawExits := exits
if in.Exits != nil {
rawExits = *in.Exits
}
rawTunnelID := cfTunnelID
if in.CFTunnelID != nil {
rawTunnelID = *in.CFTunnelID
}
exits, cfTunnelID, err = normalizeExits(rawExits, rawTunnelID)
if err != nil {
return Rule{}, err
}
}
if in.Hosts != nil {
hosts, err = parseHosts(*in.Hosts, listenPort)
if err != nil {
@@ -336,6 +402,10 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
if err != nil {
return Rule{}, err
}
exitsJSON, err := marshalExits(exits)
if err != nil {
return Rule{}, err
}
if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
for _, host := range current.Hosts {
@@ -350,9 +420,9 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
_, err = s.querier().ExecContext(ctx, `
UPDATE proxy_rules
SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, updated_at = datetime('now')
SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, exits_json = ?, cf_tunnel_id = ?, updated_at = datetime('now')
WHERE id = ?
`, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, id)
`, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, exitsJSON, cfTunnelID, id)
if err != nil {
return Rule{}, err
}
@@ -423,7 +493,7 @@ func (s *Store) Delete(ctx context.Context, id int64) error {
func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
rows, err := s.querier().QueryContext(ctx, `
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at
SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
FROM proxy_rules WHERE enabled = 1 ORDER BY sort_order ASC, id ASC
`)
if err != nil {
@@ -445,6 +515,21 @@ func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
return s.attachHosts(ctx, rules)
}
// ListEnabledLocal 只返回需要生成本机 Nginx 配置的启用规则。
func (s *Store) ListEnabledLocal(ctx context.Context) ([]Rule, error) {
rules, err := s.ListEnabled(ctx)
if err != nil {
return nil, err
}
out := make([]Rule, 0, len(rules))
for _, rule := range rules {
if rule.UsesExit(ExitLocal) {
out = append(out, rule)
}
}
return out, nil
}
func validateCreateInput(in CreateInput) (string, []Host, error) {
upstream, err := validate.Upstream(in.Upstream)
if err != nil {
@@ -654,6 +739,8 @@ func scanRule(row rowScanner) (Rule, error) {
var enabled int
var nginxMode string
var securityJSON string
var exitsJSON string
var cfTunnelID int64
var createdAt string
var updatedAt string
if err := row.Scan(
@@ -669,6 +756,8 @@ func scanRule(row rowScanner) (Rule, error) {
&rule.Name,
&rule.SortOrder,
&securityJSON,
&exitsJSON,
&cfTunnelID,
&createdAt,
&updatedAt,
); err != nil {
@@ -688,11 +777,67 @@ func scanRule(row rowScanner) (Rule, error) {
return Rule{}, err
}
rule.Security = security
rule.Exits = parseExits(exitsJSON)
rule.CFTunnelID = cfTunnelID
rule.CreatedAt = parseTime(createdAt)
rule.UpdatedAt = parseTime(updatedAt)
return rule, nil
}
func normalizeExits(raw []string, cfTunnelID int64) ([]string, int64, error) {
if len(raw) == 0 {
raw = []string{ExitLocal}
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, item := range raw {
exit := strings.ToLower(strings.TrimSpace(item))
switch exit {
case ExitLocal, ExitCloudflare:
default:
return nil, 0, fmt.Errorf("不支持的出口: %s", item)
}
if _, ok := seen[exit]; ok {
continue
}
seen[exit] = struct{}{}
out = append(out, exit)
}
sort.Strings(out)
_, usesCloudflare := seen[ExitCloudflare]
if usesCloudflare && cfTunnelID <= 0 {
return nil, 0, fmt.Errorf("选择 Cloudflare 出口时需要指定隧道")
}
if !usesCloudflare {
cfTunnelID = 0
}
return out, cfTunnelID, nil
}
func marshalExits(exits []string) (string, error) {
normalized, _, err := normalizeExits(exits, 1)
if err != nil {
return "", err
}
data, err := json.Marshal(normalized)
if err != nil {
return "", err
}
return string(data), nil
}
func parseExits(raw string) []string {
var exits []string
if err := json.Unmarshal([]byte(raw), &exits); err != nil {
return []string{ExitLocal}
}
normalized, _, err := normalizeExits(exits, 1)
if err != nil {
return []string{ExitLocal}
}
return normalized
}
func parseTime(v string) time.Time {
t, err := time.Parse("2006-01-02 15:04:05", v)
if err != nil {
+2 -2
View File
@@ -287,7 +287,7 @@ func (s *ProxyService) RollbackGlobalNginx(ctx context.Context, backupName strin
}
func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error {
rules, err := s.store.ListEnabled(ctx)
rules, err := s.store.ListEnabledLocal(ctx)
if err != nil {
return err
}
@@ -315,7 +315,7 @@ func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.
}
func (s *ProxyService) validateGlobalNginxPending(ctx context.Context, content string) error {
rules, err := s.store.ListEnabled(ctx)
rules, err := s.store.ListEnabledLocal(ctx)
if err != nil {
return err
}
+42 -8
View File
@@ -23,6 +23,12 @@ type ProxyService struct {
settings *settings.Store
nginx *nginx.Manager
notify *notify.Service
exitSync ExitSync
}
// ExitSync 处理服务发布规则变化后的外部出口同步。
type ExitSync interface {
SyncRules(ctx context.Context) error
}
func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService {
@@ -33,6 +39,10 @@ func (s *ProxyService) SetNotify(notifySvc *notify.Service) {
s.notify = notifySvc
}
func (s *ProxyService) SetExitSync(sync ExitSync) {
s.exitSync = sync
}
func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
return s.store.List(ctx)
}
@@ -42,8 +52,10 @@ func (s *ProxyService) Get(ctx context.Context, id int64) (proxy.Rule, error) {
}
func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.Rule, error) {
if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
return proxy.Rule{}, err
if proxy.HasExit(in.Exits, proxy.ExitLocal) {
if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
return proxy.Rule{}, err
}
}
rule, err := s.store.Create(ctx, in)
@@ -53,6 +65,9 @@ func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.
if err := s.applyNginx(ctx); err != nil {
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
}
if err := s.syncExits(ctx); err != nil {
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
}
return rule, nil
}
@@ -69,8 +84,14 @@ func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInpu
if in.Hosts != nil {
hosts = *in.Hosts
}
if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil {
return proxy.Rule{}, err
exits := current.Exits
if in.Exits != nil {
exits = *in.Exits
}
if proxy.HasExit(exits, proxy.ExitLocal) {
if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil {
return proxy.Rule{}, err
}
}
rule, err := s.store.Update(ctx, id, in)
@@ -80,9 +101,19 @@ func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInpu
if err := s.applyNginx(ctx); err != nil {
return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
}
if err := s.syncExits(ctx); err != nil {
rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
}
return rule, nil
}
func (s *ProxyService) syncExits(ctx context.Context) error {
if s.exitSync == nil {
return nil
}
return s.exitSync.SyncRules(ctx)
}
func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error {
return s.store.Reorder(ctx, ids)
}
@@ -95,6 +126,9 @@ func (s *ProxyService) Delete(ctx context.Context, id int64) error {
if err := s.applyNginx(ctx); err != nil {
return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
}
if err := s.syncExits(ctx); err != nil {
return fmt.Errorf("规则已删除,但 Cloudflare 同步失败:%w", err)
}
return nil
}
@@ -103,7 +137,7 @@ func (s *ProxyService) ReloadAll(ctx context.Context) error {
}
func (s *ProxyService) applyNginx(ctx context.Context) error {
rules, err := s.store.ListEnabled(ctx)
rules, err := s.store.ListEnabledLocal(ctx)
if err != nil {
return err
}
@@ -124,17 +158,17 @@ func (s *ProxyService) ValidateRule(ctx context.Context, candidate proxy.Rule, e
replaced := false
for _, r := range existing {
if r.ID == candidate.ID {
if candidate.Enabled {
if candidate.LocalEnabled() {
rules = append(rules, candidate)
}
replaced = true
continue
}
if r.Enabled {
if r.LocalEnabled() {
rules = append(rules, r)
}
}
if !replaced && candidate.Enabled {
if !replaced && candidate.LocalEnabled() {
rules = append(rules, candidate)
}
certs, err := s.loadCertSources(ctx)