feat(cloudflare): 新增 Cloudflare 隧道独立模块并发布 v1.6.0
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const minBinarySize = 10 << 20
|
||||
|
||||
// Mirror 是 cloudflared 二进制下载镜像。
|
||||
type Mirror struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Base string `json:"base"`
|
||||
}
|
||||
|
||||
// DefaultMirrors 返回内置镜像表;Base 为空表示 GitHub 官方源。
|
||||
func DefaultMirrors() []Mirror {
|
||||
return []Mirror{
|
||||
{ID: "official", Name: "GitHub 官方源"},
|
||||
{ID: "gh-proxy-org", Name: "gh-proxy.org", Base: "https://gh-proxy.org/"},
|
||||
{ID: "gh-proxy-hk", Name: "gh-proxy 香港", Base: "https://hk.gh-proxy.org/"},
|
||||
{ID: "gh-proxy-cdn", Name: "gh-proxy CDN", Base: "https://cdn.gh-proxy.org/"},
|
||||
{ID: "gh-proxy-edgeone", Name: "gh-proxy EdgeOne", Base: "https://edgeone.gh-proxy.org/"},
|
||||
}
|
||||
}
|
||||
|
||||
func findMirror(id string) Mirror {
|
||||
id = strings.TrimSpace(id)
|
||||
for _, mirror := range DefaultMirrors() {
|
||||
if mirror.ID == id {
|
||||
return mirror
|
||||
}
|
||||
}
|
||||
return DefaultMirrors()[0]
|
||||
}
|
||||
|
||||
func binaryFileName() string {
|
||||
name := "cloudflared-" + runtime.GOOS + "-" + runtime.GOARCH
|
||||
if runtime.GOOS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// DownloadURL 生成指定镜像的下载地址;version 为空表示 latest。
|
||||
func DownloadURL(mirror Mirror, version string) string {
|
||||
official := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + binaryFileName()
|
||||
if strings.TrimSpace(version) != "" {
|
||||
official = "https://github.com/cloudflare/cloudflared/releases/download/" + strings.TrimSpace(version) + "/" + binaryFileName()
|
||||
}
|
||||
if strings.TrimSpace(mirror.Base) == "" {
|
||||
return official
|
||||
}
|
||||
return mirror.Base + official
|
||||
}
|
||||
|
||||
func (s *Service) binaryPath() string {
|
||||
name := "cloudflared"
|
||||
if runtime.GOOS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
return filepath.Join(s.cfg.DataDir, "cloudflared", "bin", name)
|
||||
}
|
||||
|
||||
// BinaryVersion 返回本机 cloudflared 版本;未安装返回空串。
|
||||
func (s *Service) BinaryVersion() string {
|
||||
path := s.binaryPath()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return ""
|
||||
}
|
||||
out, err := exec.Command(path, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// DownloadBinary 下载并替换本机 cloudflared 二进制。
|
||||
func (s *Service) DownloadBinary(ctx context.Context, mirrorID string) (string, error) {
|
||||
mirror := findMirror(mirrorID)
|
||||
target := s.binaryPath()
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := target + ".download"
|
||||
_ = os.Remove(tmp)
|
||||
if err := fetchFile(ctx, DownloadURL(mirror, ""), tmp); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(tmp)
|
||||
if err != nil || info.Size() < minBinarySize {
|
||||
_ = os.Remove(tmp)
|
||||
return "", fmt.Errorf("下载文件过小,可能被镜像源截断")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
_ = os.Chmod(tmp, 0o755)
|
||||
}
|
||||
if out, err := exec.Command(tmp, "--version").CombinedOutput(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", fmt.Errorf("cloudflared 二进制不可执行: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
return s.BinaryVersion(), nil
|
||||
}
|
||||
|
||||
func fetchFile(ctx context.Context, url, path string) error {
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载 cloudflared 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载 cloudflared 失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||
return fmt.Errorf("写入 cloudflared 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDownloadURL(t *testing.T) {
|
||||
official := DownloadURL(Mirror{ID: "official"}, "")
|
||||
if !strings.Contains(official, "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-") {
|
||||
t.Fatalf("官方下载地址不正确:%s", official)
|
||||
}
|
||||
proxied := DownloadURL(Mirror{ID: "gh-proxy-org", Base: "https://gh-proxy.org/"}, "")
|
||||
if !strings.HasPrefix(proxied, "https://gh-proxy.org/https://github.com/cloudflare/cloudflared/releases/latest/download/") {
|
||||
t.Fatalf("镜像前缀拼接不正确:%s", proxied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMirrorFallback(t *testing.T) {
|
||||
if got := findMirror("not-exist"); got.ID != "official" {
|
||||
t.Fatalf("未知镜像应回退官方源:%#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type tokenPayload struct {
|
||||
AccountTag string `json:"a"`
|
||||
TunnelID string `json:"t"`
|
||||
TunnelSecret string `json:"s"`
|
||||
}
|
||||
|
||||
// DecodeToken 解析 Cloudflare Tunnel token;兼容 a/t/s 与 AccountTag/TunnelID/TunnelSecret 两种字段名。
|
||||
func DecodeToken(token string) (accountTag, tunnelID, tunnelSecret string, err error) {
|
||||
clean := strings.NewReplacer("\n", "", "\r", "", " ", "", "\t", "").Replace(strings.TrimSpace(token))
|
||||
if clean == "" {
|
||||
return "", "", "", fmt.Errorf("token 为空")
|
||||
}
|
||||
if remainder := len(clean) % 4; remainder != 0 {
|
||||
clean += strings.Repeat("=", 4-remainder)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(clean)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("token base64 解码失败: %w", err)
|
||||
}
|
||||
var payload tokenPayload
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || payload.TunnelID == "" {
|
||||
var alt struct {
|
||||
AccountTag string `json:"AccountTag"`
|
||||
TunnelID string `json:"TunnelID"`
|
||||
TunnelSecret string `json:"TunnelSecret"`
|
||||
}
|
||||
if err2 := json.Unmarshal(raw, &alt); err2 != nil {
|
||||
return "", "", "", fmt.Errorf("token JSON 解析失败: %w", err)
|
||||
}
|
||||
payload.AccountTag, payload.TunnelID, payload.TunnelSecret = alt.AccountTag, alt.TunnelID, alt.TunnelSecret
|
||||
}
|
||||
if payload.AccountTag == "" || payload.TunnelID == "" || payload.TunnelSecret == "" {
|
||||
return "", "", "", fmt.Errorf("token 缺少 AccountTag / TunnelID / TunnelSecret")
|
||||
}
|
||||
return payload.AccountTag, payload.TunnelID, payload.TunnelSecret, nil
|
||||
}
|
||||
|
||||
// CredentialsJSON 生成 cloudflared 需要的 creds.json 内容。
|
||||
func CredentialsJSON(accountTag, tunnelID, tunnelSecret string) string {
|
||||
data, _ := json.MarshalIndent(map[string]string{
|
||||
"AccountTag": accountTag,
|
||||
"TunnelID": tunnelID,
|
||||
"TunnelSecret": tunnelSecret,
|
||||
}, "", " ")
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// BuildConfig 生成 locally-managed 的 config.yml;ingress 末条由调用方保证是 catch-all。
|
||||
func BuildConfig(tunnelID, credsPath string, network NetworkSettings, ingress []IngressRule) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "tunnel: %s\n", tunnelID)
|
||||
fmt.Fprintf(&b, "credentials-file: %s\n\n", credsPath)
|
||||
writeNetworkSection(&b, network)
|
||||
b.WriteString("ingress:\n")
|
||||
for _, rule := range ingress {
|
||||
if rule.Hostname != "" {
|
||||
fmt.Fprintf(&b, " - hostname: %s\n", rule.Hostname)
|
||||
fmt.Fprintf(&b, " service: %s\n", rule.Service)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " - service: %s\n", rule.Service)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeNetworkSection(b *strings.Builder, network NetworkSettings) {
|
||||
protocol := strings.TrimSpace(network.TransportProtocol)
|
||||
edge := strings.TrimSpace(network.EdgeIPVersion)
|
||||
hasOrigin := strings.TrimSpace(network.OriginCAPool) != "" || network.NoTLSVerify || strings.TrimSpace(network.HTTPHostHeader) != ""
|
||||
if protocol != "" && protocol != "auto" {
|
||||
fmt.Fprintf(b, "protocol: %s\n", protocol)
|
||||
}
|
||||
if edge != "" && edge != "auto" {
|
||||
fmt.Fprintf(b, "edge-ip-version: \"%s\"\n", edge)
|
||||
}
|
||||
if network.HAConnections > 0 {
|
||||
fmt.Fprintf(b, "ha-connections: %d\n", network.HAConnections)
|
||||
}
|
||||
if hasOrigin {
|
||||
b.WriteString("originRequest:\n")
|
||||
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
|
||||
fmt.Fprintf(b, " caPool: %s\n", pool)
|
||||
}
|
||||
if network.NoTLSVerify {
|
||||
b.WriteString(" noTLSVerify: true\n")
|
||||
}
|
||||
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
|
||||
fmt.Fprintf(b, " httpHostHeader: %s\n", header)
|
||||
}
|
||||
}
|
||||
if protocol != "" || edge != "" || network.HAConnections > 0 || hasOrigin {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// BuildIngress 从域名列表生成 ingress,锁定三条不变式:末条 catch-all、去重、稳定排序。
|
||||
func BuildIngress(hostnames []string, service string) []IngressRule {
|
||||
seen := map[string]bool{}
|
||||
cleaned := make([]string, 0, len(hostnames))
|
||||
for _, host := range hostnames {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" || seen[host] {
|
||||
continue
|
||||
}
|
||||
seen[host] = true
|
||||
cleaned = append(cleaned, host)
|
||||
}
|
||||
sort.Strings(cleaned)
|
||||
out := make([]IngressRule, 0, len(cleaned)+1)
|
||||
for _, host := range cleaned {
|
||||
out = append(out, IngressRule{Hostname: host, Service: service})
|
||||
}
|
||||
return append(out, IngressRule{Service: "http_status:404"})
|
||||
}
|
||||
|
||||
// NormalizeService 生成回源 service;localhost 归一化为 127.0.0.1。
|
||||
func NormalizeService(protocol, host string, port int) string {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "localhost" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s:%d", protocol, host, port)
|
||||
}
|
||||
|
||||
// MaskSecret 只保留首尾少量字符,用于 API 回显与日志。
|
||||
func MaskSecret(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if len(value) <= 8 {
|
||||
return "********"
|
||||
}
|
||||
return value[:4] + "********" + value[len(value)-4:]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeToken(t *testing.T) {
|
||||
payload := `{"a":"account-1","t":"tunnel-2","s":"secret-3"}`
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
|
||||
account, tunnel, secret, err := DecodeToken(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account != "account-1" || tunnel != "tunnel-2" || secret != "secret-3" {
|
||||
t.Fatalf("解码结果不符:%s %s %s", account, tunnel, secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTokenAlternativeFields(t *testing.T) {
|
||||
payload := `{"AccountTag":"a","TunnelID":"t","TunnelSecret":"s"}`
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
|
||||
if _, _, _, err := DecodeToken(encoded); err != nil {
|
||||
t.Fatalf("兼容字段名解析失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTokenRejectsInvalid(t *testing.T) {
|
||||
if _, _, _, err := DecodeToken("not-base64"); err == nil {
|
||||
t.Fatal("非法 token 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIngressInvariants(t *testing.T) {
|
||||
got := BuildIngress([]string{"B.example.com", "a.example.com", "a.example.com", " "}, "http://127.0.0.1:80")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("应生成 2 条域名 + 1 条 catch-all:%#v", got)
|
||||
}
|
||||
if got[0].Hostname != "a.example.com" || got[1].Hostname != "b.example.com" {
|
||||
t.Fatalf("域名应去重并稳定排序:%#v", got)
|
||||
}
|
||||
if got[2].Hostname != "" || got[2].Service != "http_status:404" {
|
||||
t.Fatalf("末条必须是 catch-all:%#v", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig(t *testing.T) {
|
||||
config := BuildConfig("tunnel-1", "/data/creds.json", DefaultNetworkSettings(), BuildIngress([]string{"a.example.com"}, "http://127.0.0.1:80"))
|
||||
for _, want := range []string{
|
||||
"tunnel: tunnel-1",
|
||||
"credentials-file: /data/creds.json",
|
||||
"protocol: http2",
|
||||
`edge-ip-version: "4"`,
|
||||
"ha-connections: 2",
|
||||
"- hostname: a.example.com",
|
||||
"service: http://127.0.0.1:80",
|
||||
"- service: http_status:404",
|
||||
} {
|
||||
if !strings.Contains(config, want) {
|
||||
t.Fatalf("config.yml 缺少 %q:\n%s", want, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeService(t *testing.T) {
|
||||
if got := NormalizeService("http", "localhost", 80); got != "http://127.0.0.1:80" {
|
||||
t.Fatalf("NormalizeService() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package cloudflared
|
||||
|
||||
import "syscall"
|
||||
|
||||
func hideWindowAttr() *syscall.SysProcAttr {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package cloudflared
|
||||
|
||||
import "syscall"
|
||||
|
||||
func hideWindowAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{HideWindow: true}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
)
|
||||
|
||||
var metricsPortPattern = regexp.MustCompile(`starting metrics server on 127\.0\.0\.1:(\d+)/metrics`)
|
||||
|
||||
type processState struct {
|
||||
cmd *exec.Cmd
|
||||
status string
|
||||
lastError string
|
||||
metricsPort int
|
||||
metrics RuntimeStatus
|
||||
logMu sync.Mutex
|
||||
}
|
||||
|
||||
// Manager 托管 cloudflared 子进程、日志与 metrics。
|
||||
type Manager struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
mu sync.Mutex
|
||||
processes map[int64]*processState
|
||||
}
|
||||
|
||||
func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
|
||||
return &Manager{cfg: cfg, logger: logger, processes: map[int64]*processState{}}
|
||||
}
|
||||
|
||||
func buildRunArgs(tunnel Tunnel, configPath, token string) []string {
|
||||
args := []string{"tunnel"}
|
||||
if tunnel.Mode == ModeTokenRemote {
|
||||
args = append(args, networkArgs(tunnel.Network)...)
|
||||
return append(args, "run", "--token", token)
|
||||
}
|
||||
args = append(args, "--config", configPath)
|
||||
args = append(args, networkArgs(tunnel.Network)...)
|
||||
return append(args, "run")
|
||||
}
|
||||
|
||||
func networkArgs(network NetworkSettings) []string {
|
||||
args := []string{}
|
||||
if network.HAConnections > 0 {
|
||||
args = append(args, "--ha-connections", strconv.Itoa(network.HAConnections))
|
||||
}
|
||||
if pool := strings.TrimSpace(network.OriginCAPool); pool != "" {
|
||||
args = append(args, "--origin-ca-pool", pool)
|
||||
}
|
||||
if network.NoTLSVerify {
|
||||
args = append(args, "--no-tls-verify")
|
||||
}
|
||||
if header := strings.TrimSpace(network.HTTPHostHeader); header != "" {
|
||||
args = append(args, "--http-host-header", header)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func buildEnv(network NetworkSettings) []string {
|
||||
env := os.Environ()
|
||||
if protocol := strings.TrimSpace(network.TransportProtocol); protocol != "" {
|
||||
env = append(env, "TUNNEL_TRANSPORT_PROTOCOL="+protocol)
|
||||
}
|
||||
if edge := strings.TrimSpace(network.EdgeIPVersion); edge != "" {
|
||||
env = append(env, "TUNNEL_EDGE_IP_VERSION="+edge)
|
||||
}
|
||||
if network.ProxyMode == "disabled" {
|
||||
env = removeEnv(env, []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"})
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func removeEnv(env []string, names []string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, item := range env {
|
||||
remove := false
|
||||
for _, name := range names {
|
||||
if strings.HasPrefix(item, name+"=") {
|
||||
remove = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !remove {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Start 启动一条隧道的 cloudflared 子进程。
|
||||
func (m *Manager) Start(tunnel Tunnel, binaryPath, configPath, token string) error {
|
||||
m.mu.Lock()
|
||||
if _, exists := m.processes[tunnel.ID]; exists {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("隧道已在运行中")
|
||||
}
|
||||
state := &processState{status: "starting"}
|
||||
m.processes[tunnel.ID] = state
|
||||
m.mu.Unlock()
|
||||
|
||||
logFile, err := os.OpenFile(tunnel.LogPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
m.remove(tunnel.ID)
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(binaryPath, buildRunArgs(tunnel, configPath, token)...)
|
||||
cmd.Env = buildEnv(tunnel.Network)
|
||||
cmd.SysProcAttr = hideWindowAttr()
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = logFile.Close()
|
||||
m.remove(tunnel.ID)
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
_ = logFile.Close()
|
||||
m.remove(tunnel.ID)
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = logFile.Close()
|
||||
m.remove(tunnel.ID)
|
||||
return fmt.Errorf("启动 cloudflared 失败: %w", err)
|
||||
}
|
||||
state.cmd = cmd
|
||||
go m.pipeLogs(tunnel.ID, stdout, logFile)
|
||||
go m.pipeLogs(tunnel.ID, stderr, logFile)
|
||||
go m.wait(tunnel.ID, cmd, logFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) pipeLogs(id int64, reader io.Reader, logFile *os.File) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
m.writeLog(id, logFile, line)
|
||||
m.detectMetrics(id, line)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) writeLog(id int64, logFile *os.File, line string) {
|
||||
m.mu.Lock()
|
||||
state := m.processes[id]
|
||||
m.mu.Unlock()
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.logMu.Lock()
|
||||
defer state.logMu.Unlock()
|
||||
_, _ = fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), line)
|
||||
}
|
||||
|
||||
func (m *Manager) detectMetrics(id int64, line string) {
|
||||
match := metricsPortPattern.FindStringSubmatch(line)
|
||||
if len(match) != 2 {
|
||||
return
|
||||
}
|
||||
port, err := strconv.Atoi(match[1])
|
||||
if err != nil || port <= 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
state := m.processes[id]
|
||||
if state == nil || state.metricsPort == port {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
state.metricsPort = port
|
||||
m.mu.Unlock()
|
||||
go m.scrapeMetrics(id, port)
|
||||
}
|
||||
|
||||
func (m *Manager) scrapeMetrics(id int64, port int) {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
metricsURL := fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
|
||||
readyURL := fmt.Sprintf("http://127.0.0.1:%d/ready", port)
|
||||
lastIn, lastOut := 0.0, 0.0
|
||||
for {
|
||||
time.Sleep(2 * time.Second)
|
||||
if !m.running(id) {
|
||||
return
|
||||
}
|
||||
text, err := fetchText(client, metricsURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
snapshot := parseMetrics(text)
|
||||
status := RuntimeStatus{
|
||||
Running: true, Status: "connecting", MetricsPort: port,
|
||||
Connections: snapshot.connections, LatencyMS: snapshot.latencyMS,
|
||||
Transport: snapshot.transport,
|
||||
}
|
||||
if snapshot.hasQUIC {
|
||||
status.BandwidthIn = math.Max(0, snapshot.bytesIn-lastIn) / 2
|
||||
status.BandwidthOut = math.Max(0, snapshot.bytesOut-lastOut) / 2
|
||||
lastIn, lastOut = snapshot.bytesIn, snapshot.bytesOut
|
||||
} else {
|
||||
status.BandwidthNotice = "HTTP/2 官方 metrics 不含字节指标"
|
||||
}
|
||||
if snapshot.connections > 0 {
|
||||
status.Status = "connected"
|
||||
} else if isReady(client, readyURL) {
|
||||
status.Status = "reconnecting"
|
||||
}
|
||||
m.mu.Lock()
|
||||
if state := m.processes[id]; state != nil {
|
||||
state.metrics = status
|
||||
state.status = status.Status
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) running(id int64) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.processes[id] != nil
|
||||
}
|
||||
|
||||
func (m *Manager) wait(id int64, cmd *exec.Cmd, logFile *os.File) {
|
||||
err := cmd.Wait()
|
||||
_ = logFile.Close()
|
||||
m.mu.Lock()
|
||||
state := m.processes[id]
|
||||
if state != nil {
|
||||
if err != nil {
|
||||
state.status = "failed"
|
||||
state.lastError = err.Error()
|
||||
} else {
|
||||
state.status = "stopped"
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) remove(id int64) {
|
||||
m.mu.Lock()
|
||||
delete(m.processes, id)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stop 停止隧道子进程。
|
||||
func (m *Manager) Stop(id int64) error {
|
||||
m.mu.Lock()
|
||||
state := m.processes[id]
|
||||
delete(m.processes, id)
|
||||
m.mu.Unlock()
|
||||
if state == nil || state.cmd == nil || state.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
_ = state.cmd.Process.Kill()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status 返回运行状态与最近一次 metrics 快照。
|
||||
func (m *Manager) Status(id int64) RuntimeStatus {
|
||||
m.mu.Lock()
|
||||
state := m.processes[id]
|
||||
m.mu.Unlock()
|
||||
if state == nil {
|
||||
return RuntimeStatus{Status: "stopped"}
|
||||
}
|
||||
out := state.metrics
|
||||
out.Status = state.status
|
||||
out.LastError = state.lastError
|
||||
out.MetricsPort = state.metricsPort
|
||||
out.Running = state.status != "stopped" && state.status != "failed"
|
||||
return out
|
||||
}
|
||||
|
||||
// Logs 读取日志文件末尾若干行。
|
||||
func (m *Manager) Logs(logPath string, lines int) (string, error) {
|
||||
if lines <= 0 {
|
||||
lines = 200
|
||||
}
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
all := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if len(all) > lines {
|
||||
all = all[len(all)-lines:]
|
||||
}
|
||||
return strings.Join(all, "\n"), nil
|
||||
}
|
||||
|
||||
type metricsSnapshot struct {
|
||||
connections int
|
||||
latencyMS float64
|
||||
bytesIn float64
|
||||
bytesOut float64
|
||||
transport string
|
||||
hasQUIC bool
|
||||
}
|
||||
|
||||
func parseMetrics(text string) metricsSnapshot {
|
||||
snapshot := metricsSnapshot{transport: "http2"}
|
||||
var rttSum, rttCount float64
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "cloudflared_tunnel_ha_connections"):
|
||||
snapshot.connections = int(parseMetricValue(line))
|
||||
case strings.HasPrefix(line, "quic_client_receive_bytes"):
|
||||
snapshot.bytesIn += parseMetricValue(line)
|
||||
snapshot.hasQUIC = true
|
||||
case strings.HasPrefix(line, "quic_client_sent_bytes"):
|
||||
snapshot.bytesOut += parseMetricValue(line)
|
||||
snapshot.hasQUIC = true
|
||||
case strings.HasPrefix(line, "quic_client_smoothed_rtt"):
|
||||
rttSum += parseMetricValue(line)
|
||||
rttCount++
|
||||
snapshot.hasQUIC = true
|
||||
}
|
||||
}
|
||||
if snapshot.hasQUIC {
|
||||
snapshot.transport = "quic"
|
||||
if rttCount > 0 {
|
||||
snapshot.latencyMS = rttSum / rttCount
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func parseMetricValue(line string) float64 {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
value, _ := strconv.ParseFloat(fields[len(fields)-1], 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func fetchText(client *http.Client, url string) (string, error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func isReady(client *http.Client, url string) bool {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildRunArgs(t *testing.T) {
|
||||
local := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenLocal, Network: DefaultNetworkSettings()}, "/data/config.yml", ""), " ")
|
||||
for _, want := range []string{"tunnel", "--config /data/config.yml", "--ha-connections 2", "run"} {
|
||||
if !strings.Contains(local, want) {
|
||||
t.Fatalf("本地规则启动参数缺少 %q:%s", want, local)
|
||||
}
|
||||
}
|
||||
remote := strings.Join(buildRunArgs(Tunnel{Mode: ModeTokenRemote, Network: DefaultNetworkSettings()}, "", "token-123"), " ")
|
||||
if strings.Contains(remote, "--config") || !strings.Contains(remote, "run --token token-123") {
|
||||
t.Fatalf("云端规则模式不应使用 --config:%s", remote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetrics(t *testing.T) {
|
||||
text := strings.Join([]string{
|
||||
"cloudflared_tunnel_ha_connections 2",
|
||||
"quic_client_receive_bytes 1000",
|
||||
"quic_client_sent_bytes 500",
|
||||
"quic_client_smoothed_rtt 0.02",
|
||||
}, "\n")
|
||||
snapshot := parseMetrics(text)
|
||||
if snapshot.connections != 2 || !snapshot.hasQUIC || snapshot.transport != "quic" {
|
||||
t.Fatalf("QUIC metrics 解析不符:%#v", snapshot)
|
||||
}
|
||||
if snapshot.latencyMS <= 0 {
|
||||
t.Fatalf("RTT 解析失败:%#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetricsHTTP2(t *testing.T) {
|
||||
snapshot := parseMetrics("cloudflared_tunnel_ha_connections 1")
|
||||
if snapshot.hasQUIC || snapshot.transport != "http2" || snapshot.connections != 1 {
|
||||
t.Fatalf("HTTP/2 metrics 解析不符:%#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEnvDisablesProxy(t *testing.T) {
|
||||
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8080")
|
||||
env := buildEnv(NetworkSettings{ProxyMode: "disabled"})
|
||||
for _, item := range env {
|
||||
if strings.HasPrefix(item, "HTTP_PROXY=") {
|
||||
t.Fatalf("禁用代理时不应保留 HTTP_PROXY:%v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cloudflared
|
||||
|
||||
import "time"
|
||||
|
||||
// Mode 是隧道的托管模式;不同模式决定启动参数与规则生效侧。
|
||||
const (
|
||||
ModeTokenLocal = "token-local" // token 解码成本地凭据,规则由 Fonu 生成
|
||||
ModeTokenRemote = "token-remote" // 直接用 --token 启动,规则由 Cloudflare 面板管理
|
||||
ModeAccountLocal = "account-local" // cert.pem + credentials,规则本地生成,可云端同步
|
||||
)
|
||||
|
||||
// NetworkSettings 是 cloudflared 的网络调优参数。
|
||||
type NetworkSettings struct {
|
||||
TransportProtocol string `json:"transport_protocol"` // http2 / quic / auto
|
||||
EdgeIPVersion string `json:"edge_ip_version"` // 4 / 6 / auto
|
||||
HAConnections int `json:"ha_connections"`
|
||||
ProxyMode string `json:"proxy_mode"` // system / disabled
|
||||
OriginCAPool string `json:"origin_ca_pool"`
|
||||
NoTLSVerify bool `json:"no_tls_verify"`
|
||||
HTTPHostHeader string `json:"http_host_header"`
|
||||
}
|
||||
|
||||
// DefaultNetworkSettings 返回国内环境更稳的默认值。
|
||||
func DefaultNetworkSettings() NetworkSettings {
|
||||
return NetworkSettings{
|
||||
TransportProtocol: "http2",
|
||||
EdgeIPVersion: "4",
|
||||
HAConnections: 2,
|
||||
ProxyMode: "system",
|
||||
}
|
||||
}
|
||||
|
||||
// IngressRule 对应 config.yml 的 ingress 条目。
|
||||
type IngressRule struct {
|
||||
Hostname string `json:"hostname,omitempty" yaml:"hostname,omitempty"`
|
||||
Service string `json:"service" yaml:"service"`
|
||||
}
|
||||
|
||||
// Tunnel 是一条 Cloudflare 隧道。
|
||||
type Tunnel struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
AccountTag string `json:"account_tag"`
|
||||
CredentialsEnc string `json:"-"`
|
||||
CertPEMEnc string `json:"-"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
CredsPath string `json:"creds_path"`
|
||||
LogPath string `json:"log_path"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"last_error"`
|
||||
MetricsPort int `json:"metrics_port"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateInput 是创建 / 更新隧道时的入参。
|
||||
type CreateInput struct {
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
Token string `json:"token"`
|
||||
Hostnames []string `json:"hostnames"`
|
||||
OriginService string `json:"origin_service"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
}
|
||||
|
||||
// RuntimeStatus 是隧道运行状态与指标快照。
|
||||
type RuntimeStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"last_error"`
|
||||
MetricsPort int `json:"metrics_port"`
|
||||
Connections int `json:"connections"`
|
||||
LatencyMS float64 `json:"latency_ms"`
|
||||
BandwidthIn float64 `json:"bandwidth_in"`
|
||||
BandwidthOut float64 `json:"bandwidth_out"`
|
||||
Transport string `json:"transport"`
|
||||
BandwidthNotice string `json:"bandwidth_notice,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
)
|
||||
|
||||
const defaultOriginService = "http://127.0.0.1:80"
|
||||
|
||||
// RuleSource 提供本机反代规则,用于推导 ingress 候选域名。
|
||||
type RuleSource interface {
|
||||
List(ctx context.Context) ([]proxy.Rule, error)
|
||||
}
|
||||
|
||||
// Service 是 Cloudflare 隧道模块的门面。
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
store *Store
|
||||
secretBox *secret.Box
|
||||
logger *slog.Logger
|
||||
manager *Manager
|
||||
rules RuleSource
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, rules RuleSource) *Service {
|
||||
moduleLogger := logger.With("module", "CLOUDFLARED")
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
secretBox: secretBox,
|
||||
logger: moduleLogger,
|
||||
manager: NewManager(cfg, moduleLogger),
|
||||
rules: rules,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) EnsureDirs() error {
|
||||
return os.MkdirAll(filepath.Join(s.cfg.DataDir, "cloudflared", "bin"), 0o755)
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]Tunnel, error) {
|
||||
return s.store.List(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id int64) (Tunnel, error) {
|
||||
return s.store.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Create 解析 token、生成 creds.json 与 config.yml,并写入数据库。
|
||||
func (s *Service) Create(ctx context.Context, in CreateInput) (Tunnel, error) {
|
||||
if err := validateInput(in); err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
accountTag, tunnelID, tunnelSecret, err := DecodeToken(in.Token)
|
||||
if err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
dir := s.tunnelDir(in.Name)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
configPath := filepath.Join(dir, "config.yml")
|
||||
credsPath := filepath.Join(dir, "creds.json")
|
||||
logPath := filepath.Join(dir, "cloudflared.log")
|
||||
origin := strings.TrimSpace(in.OriginService)
|
||||
if origin == "" {
|
||||
origin = defaultOriginService
|
||||
}
|
||||
ingress := BuildIngress(in.Hostnames, origin)
|
||||
configContent := BuildConfig(tunnelID, credsPath, in.Network, ingress)
|
||||
if err := fsutil.WriteFileAtomic(credsPath, []byte(CredentialsJSON(accountTag, tunnelID, tunnelSecret)), 0o600); err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
if err := fsutil.WriteFileAtomic(configPath, []byte(configContent), 0o640); err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
encrypted, err := s.secretBox.Encrypt(in.Token)
|
||||
if err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
return s.store.Create(ctx, in, tunnelID, accountTag, encrypted, "", configPath, credsPath, logPath)
|
||||
}
|
||||
|
||||
// Update 更新隧道配置;token 留空时保留原凭据。
|
||||
func (s *Service) Update(ctx context.Context, id int64, in CreateInput) (Tunnel, error) {
|
||||
current, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
_ = s.manager.Stop(id)
|
||||
accountTag, tunnelID := current.AccountTag, current.TunnelID
|
||||
encrypted := current.CredentialsEnc
|
||||
if token := strings.TrimSpace(in.Token); token != "" {
|
||||
accountTag, tunnelID, tunnelSecret, err := DecodeToken(token)
|
||||
if err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
encrypted, err = s.secretBox.Encrypt(token)
|
||||
if err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
if err := fsutil.WriteFileAtomic(current.CredsPath, []byte(CredentialsJSON(accountTag, tunnelID, tunnelSecret)), 0o600); err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
}
|
||||
origin := strings.TrimSpace(in.OriginService)
|
||||
if origin == "" {
|
||||
origin = defaultOriginService
|
||||
}
|
||||
configContent := BuildConfig(tunnelID, current.CredsPath, in.Network, BuildIngress(in.Hostnames, origin))
|
||||
if err := fsutil.WriteFileAtomic(current.ConfigPath, []byte(configContent), 0o640); err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
return s.store.Update(ctx, id, in, tunnelID, accountTag, encrypted, current.CertPEMEnc,
|
||||
current.ConfigPath, current.CredsPath, current.LogPath)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id int64) error {
|
||||
current, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.manager.Stop(id)
|
||||
if dir := filepath.Dir(current.ConfigPath); dir != "" && strings.HasPrefix(dir, s.cfg.DataDir) {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
return s.store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context, id int64) (Tunnel, error) {
|
||||
tunnel, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
binaryPath := s.binaryPath()
|
||||
if _, err := os.Stat(binaryPath); err != nil {
|
||||
return Tunnel{}, fmt.Errorf("cloudflared 未安装,请先下载二进制")
|
||||
}
|
||||
token := ""
|
||||
if tunnel.Mode == ModeTokenRemote {
|
||||
plain, err := s.secretBox.Decrypt(tunnel.CredentialsEnc)
|
||||
if err != nil {
|
||||
return Tunnel{}, fmt.Errorf("凭据解密失败: %w", err)
|
||||
}
|
||||
token = plain
|
||||
}
|
||||
if err := s.manager.Start(tunnel, binaryPath, tunnel.ConfigPath, token); err != nil {
|
||||
_ = s.store.SetRuntime(ctx, id, "failed", err.Error(), 0)
|
||||
return Tunnel{}, err
|
||||
}
|
||||
_ = s.store.SetRuntime(ctx, id, "starting", "", 0)
|
||||
return s.store.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Stop(ctx context.Context, id int64) (Tunnel, error) {
|
||||
_ = s.manager.Stop(id)
|
||||
_ = s.store.SetRuntime(ctx, id, "stopped", "", 0)
|
||||
return s.store.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Restart(ctx context.Context, id int64) (Tunnel, error) {
|
||||
_, _ = s.Stop(ctx, id)
|
||||
return s.Start(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) RuntimeStatus(ctx context.Context, id int64) (RuntimeStatus, error) {
|
||||
if _, err := s.store.Get(ctx, id); err != nil {
|
||||
return RuntimeStatus{}, err
|
||||
}
|
||||
return s.manager.Status(id), nil
|
||||
}
|
||||
|
||||
func (s *Service) Logs(ctx context.Context, id int64, lines int) (string, error) {
|
||||
tunnel, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.manager.Logs(tunnel.LogPath, lines)
|
||||
}
|
||||
|
||||
func (s *Service) PreviewConfig(ctx context.Context, id int64) (string, error) {
|
||||
tunnel, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(tunnel.ConfigPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// IngressCandidates 从本机反代规则推导可暴露的域名候选。
|
||||
func (s *Service) IngressCandidates(ctx context.Context) ([]string, error) {
|
||||
if s.rules == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
rules, err := s.rules.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts := []string{}
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, group := range rule.PortGroups() {
|
||||
hosts = append(hosts, group.Hostnames...)
|
||||
}
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
// StartAuto 在应用启动时拉起标记了 auto_start 的隧道。
|
||||
func (s *Service) StartAuto(ctx context.Context) {
|
||||
tunnels, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("读取 Cloudflare 隧道失败", "error", err.Error())
|
||||
return
|
||||
}
|
||||
for _, tunnel := range tunnels {
|
||||
if !tunnel.AutoStart {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Start(ctx, tunnel.ID); err != nil {
|
||||
s.logger.Warn("自动启动 Cloudflare 隧道失败", "tunnel_id", tunnel.ID, "error", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) tunnelDir(name string) string {
|
||||
safe := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, strings.TrimSpace(name))
|
||||
return filepath.Join(s.cfg.DataDir, "cloudflared", "tunnels", safe)
|
||||
}
|
||||
|
||||
func validateInput(in CreateInput) error {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return fmt.Errorf("隧道名称不能为空")
|
||||
}
|
||||
switch in.Mode {
|
||||
case ModeTokenLocal, ModeTokenRemote:
|
||||
default:
|
||||
return fmt.Errorf("暂不支持该模式,当前仅支持 token-local / token-remote")
|
||||
}
|
||||
if strings.TrimSpace(in.Token) == "" {
|
||||
return fmt.Errorf("token 不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package cloudflared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
const tunnelSelect = `
|
||||
SELECT id, name, mode, COALESCE(tunnel_id, ''), COALESCE(account_tag, ''),
|
||||
COALESCE(credentials_enc, ''), COALESCE(cert_pem_enc, ''),
|
||||
COALESCE(config_path, ''), COALESCE(creds_path, ''), COALESCE(log_path, ''),
|
||||
COALESCE(network_json, '{}'), COALESCE(status, 'stopped'), COALESCE(last_error, ''),
|
||||
COALESCE(metrics_port, 0), COALESCE(auto_start, 0), created_at, updated_at
|
||||
FROM cf_tunnels`
|
||||
|
||||
func (s *Store) List(ctx context.Context) ([]Tunnel, error) {
|
||||
rows, err := s.db.QueryContext(ctx, tunnelSelect+` ORDER BY id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Tunnel{}
|
||||
for rows.Next() {
|
||||
item, err := scanTunnel(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, id int64) (Tunnel, error) {
|
||||
item, err := scanTunnel(s.db.QueryRowContext(ctx, tunnelSelect+` WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Tunnel{}, fmt.Errorf("Cloudflare 隧道不存在")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
|
||||
network, _ := json.Marshal(in.Network)
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO cf_tunnels(name, mode, tunnel_id, account_tag, credentials_enc, cert_pem_enc,
|
||||
config_path, creds_path, log_path, network_json, status, auto_start, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'stopped', ?, datetime('now'))
|
||||
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
|
||||
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart))
|
||||
if err != nil {
|
||||
return Tunnel{}, mapUniqueName(err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id int64, in CreateInput, tunnelID, accountTag, credentialsEnc, certPEMEnc, configPath, credsPath, logPath string) (Tunnel, error) {
|
||||
network, _ := json.Marshal(in.Network)
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE cf_tunnels
|
||||
SET name = ?, mode = ?, tunnel_id = ?, account_tag = ?, credentials_enc = ?,
|
||||
cert_pem_enc = ?, config_path = ?, creds_path = ?, log_path = ?,
|
||||
network_json = ?, auto_start = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, in.Name, in.Mode, tunnelID, accountTag, credentialsEnc, certPEMEnc,
|
||||
configPath, credsPath, logPath, string(network), boolInt(in.AutoStart), id)
|
||||
if err != nil {
|
||||
return Tunnel{}, mapUniqueName(err)
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM cf_tunnels WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError string, metricsPort int) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE cf_tunnels SET status = ?, last_error = ?, metrics_port = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, status, lastError, metricsPort, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanTunnel(row interface{ Scan(dest ...any) error }) (Tunnel, error) {
|
||||
var item Tunnel
|
||||
var networkJSON, created, updated string
|
||||
var autoStart int
|
||||
err := row.Scan(&item.ID, &item.Name, &item.Mode, &item.TunnelID, &item.AccountTag,
|
||||
&item.CredentialsEnc, &item.CertPEMEnc, &item.ConfigPath, &item.CredsPath, &item.LogPath,
|
||||
&networkJSON, &item.Status, &item.LastError, &item.MetricsPort, &autoStart, &created, &updated)
|
||||
if err != nil {
|
||||
return Tunnel{}, err
|
||||
}
|
||||
item.AutoStart = autoStart == 1
|
||||
item.Network = DefaultNetworkSettings()
|
||||
_ = json.Unmarshal([]byte(networkJSON), &item.Network)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func parseTime(raw string) time.Time {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05"} {
|
||||
if parsed, err := time.Parse(layout, raw); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mapUniqueName(err error) error {
|
||||
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
|
||||
return fmt.Errorf("Cloudflare 隧道名称已存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user