324 lines
10 KiB
Go
324 lines
10 KiB
Go
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: "没有 Havline 接管的 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
|
|
}
|