This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user