feat(cloudflare): 完成隧道接入、运维与 P0 体验优化
This commit is contained in:
@@ -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) == "" {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 隧道配置")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 只回显掩码,不返回明文。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user