376 lines
9.1 KiB
Go
376 lines
9.1 KiB
Go
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
|
|
}
|