feat(cloudflare): 新增 Cloudflare 隧道独立模块并发布 v1.6.0

This commit is contained in:
2026-09-28 16:36:05 +08:00
parent 66081e93d5
commit a8a33e87d2
30 changed files with 2133 additions and 56 deletions
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/fonu/fonu/internal/cloudflared"
)
type CloudflareHandler struct {
svc *cloudflared.Service
}
func NewCloudflareHandler(svc *cloudflared.Service) *CloudflareHandler {
return &CloudflareHandler{svc: svc}
}
func (h *CloudflareHandler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *CloudflareHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Create(w http.ResponseWriter, r *http.Request) {
var input cloudflared.CreateInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
item, err := h.svc.Create(r.Context(), input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
var input cloudflared.CreateInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
item, err := h.svc.Update(r.Context(), id, input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *CloudflareHandler) Start(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Start)
}
func (h *CloudflareHandler) Stop(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Stop)
}
func (h *CloudflareHandler) Restart(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Restart)
}
func (h *CloudflareHandler) tunnelAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) (cloudflared.Tunnel, error)) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := fn(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Status(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
status, err := h.svc.RuntimeStatus(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *CloudflareHandler) Logs(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
lines, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("lines")))
output, err := h.svc.Logs(r.Context(), id, lines)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"output": output})
}
func (h *CloudflareHandler) Config(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
content, err := h.svc.PreviewConfig(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"content": content})
}
func (h *CloudflareHandler) BinaryInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"version": h.svc.BinaryVersion(),
"mirrors": cloudflared.DefaultMirrors(),
})
}
func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) {
var request struct {
Mirror string `json:"mirror"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
version, err := h.svc.DownloadBinary(r.Context(), request.Mirror)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
func (h *CloudflareHandler) IngressCandidates(w http.ResponseWriter, r *http.Request) {
hosts, err := h.svc.IngressCandidates(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"hostnames": hosts})
}
func normalizeCloudflareNetwork(network cloudflared.NetworkSettings) cloudflared.NetworkSettings {
defaults := cloudflared.DefaultNetworkSettings()
if strings.TrimSpace(network.TransportProtocol) == "" {
network.TransportProtocol = defaults.TransportProtocol
}
if strings.TrimSpace(network.EdgeIPVersion) == "" {
network.EdgeIPVersion = defaults.EdgeIPVersion
}
if network.HAConnections <= 0 {
network.HAConnections = defaults.HAConnections
}
if strings.TrimSpace(network.ProxyMode) == "" {
network.ProxyMode = defaults.ProxyMode
}
return network
}
+19 -17
View File
@@ -8,6 +8,7 @@ import (
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/backup"
"github.com/fonu/fonu/internal/chinacidr"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/ddns"
"github.com/fonu/fonu/internal/discovery"
@@ -21,21 +22,22 @@ import (
)
type Deps struct {
Config config.Config
Logger *slog.Logger
Auth *auth.Service
Proxy *service.ProxyService
DDNS *ddns.Service
ACME *acme.Service
Settings *settings.Store
ChinaCIDR *chinacidr.Service
Backup *backup.Service
Metrics *metrics.Store
Discovery *discovery.Service
FRPMulti *frp.Service
Traffic *traffic.Collector
Notify *notify.Service
Updater *updater.Client
StaticFS fs.FS
StartedAt string
Config config.Config
Logger *slog.Logger
Auth *auth.Service
Proxy *service.ProxyService
DDNS *ddns.Service
ACME *acme.Service
Settings *settings.Store
ChinaCIDR *chinacidr.Service
Backup *backup.Service
Metrics *metrics.Store
Discovery *discovery.Service
FRPMulti *frp.Service
Cloudflare *cloudflared.Service
Traffic *traffic.Collector
Notify *notify.Service
Updater *updater.Client
StaticFS fs.FS
StartedAt string
}
+20
View File
@@ -39,6 +39,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
metricsHandler: NewMetricsHandler(deps.Metrics),
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
cloudflareHandler: NewCloudflareHandler(deps.Cloudflare),
updateHandler: NewUpdateHandler(deps.Updater),
authSvc: deps.Auth,
staticFS: deps.StaticFS,
@@ -190,6 +191,24 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
}
if deps.Cloudflare != nil {
cf := r.cloudflareHandler
protect("GET /api/cloudflare/tunnels", cf.List)
protect("POST /api/cloudflare/tunnels", cf.Create)
protect("GET /api/cloudflare/tunnels/{id}", cf.Get)
protect("PUT /api/cloudflare/tunnels/{id}", cf.Update)
protect("DELETE /api/cloudflare/tunnels/{id}", cf.Delete)
protect("POST /api/cloudflare/tunnels/{id}/start", cf.Start)
protect("POST /api/cloudflare/tunnels/{id}/stop", cf.Stop)
protect("POST /api/cloudflare/tunnels/{id}/restart", cf.Restart)
protect("GET /api/cloudflare/tunnels/{id}/status", cf.Status)
protect("GET /api/cloudflare/tunnels/{id}/logs", cf.Logs)
protect("GET /api/cloudflare/tunnels/{id}/config", cf.Config)
protect("GET /api/cloudflare/binary", cf.BinaryInfo)
protect("POST /api/cloudflare/binary/download", cf.DownloadBinary)
protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates)
}
if deps.StaticFS != nil {
mux.Handle("/", r.spaHandler())
}
@@ -224,6 +243,7 @@ type Router struct {
metricsHandler *MetricsHandler
discoveryHandler *DiscoveryHandler
frpmultiHandler *FRPMultiHandler
cloudflareHandler *CloudflareHandler
updateHandler *UpdateHandler
authSvc *auth.Service
staticFS fs.FS
+8 -5
View File
@@ -13,6 +13,7 @@ import (
"github.com/fonu/fonu/internal/auth"
"github.com/fonu/fonu/internal/certificate"
"github.com/fonu/fonu/internal/cloudflared"
"github.com/fonu/fonu/internal/config"
"github.com/fonu/fonu/internal/db"
"github.com/fonu/fonu/internal/frp"
@@ -42,13 +43,15 @@ func TestProtectedRoutesRequireAuth(t *testing.T) {
cfg := config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
frpSvc := frp.NewService(cfg, frp.NewStore(conn), box, logger, certificate.NewStore(conn))
cfSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), box, logger, nil)
handler, specs := newRouterWithSpecs(Deps{
Config: cfg,
Auth: authSvc,
FRPMulti: frpSvc,
Settings: settings.NewStore(conn),
Logger: logger,
Config: cfg,
Auth: authSvc,
FRPMulti: frpSvc,
Cloudflare: cfSvc,
Settings: settings.NewStore(conn),
Logger: logger,
})
protected, public := 0, 0