Files
Havline/internal/frp/diagnostics.go
T

252 lines
8.3 KiB
Go
Raw Normal View History

2026-09-23 09:40:59 +08:00
package frp
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
)
// 诊断状态常量:ok 已确认可用;failed 已确认失败;unknown 无法确认(区别于失败)。
const (
diagStateOK = "ok"
diagStateFailed = "failed"
diagStateUnknown = "unknown"
diagTCPTimeout = 3 * time.Second
diagDNSTimeout = 2 * time.Second
diagnosticsCacheTTL = 15 * time.Second
diagnosticsMaxInFlight = 4
)
type diagnosticsCacheEntry struct {
diagnostics ServerDiagnostics
expiresAt time.Time
}
// ServerDiagnostics 返回服务端诊断结果。带短 TTL 缓存与并发上限:
// 列表页轮询不应同步等待定位等外部 API,也不能无限叠加探测请求。
func (s *Service) ServerDiagnostics(ctx context.Context, id int64) (ServerDiagnostics, error) {
server, err := s.store.GetServer(ctx, id)
if err != nil {
return ServerDiagnostics{}, err
}
return s.runDiagnostics(ctx, server), nil
}
func (s *Service) runDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
s.diagMu.Lock()
if entry, ok := s.diagCache[server.ID]; ok && time.Now().Before(entry.expiresAt) {
cached := entry.diagnostics
s.diagMu.Unlock()
return cached
}
// 并发上限:超限时返回旧结果或 unknown,不排队等待
if s.diagInFlight >= diagnosticsMaxInFlight {
cached, ok := s.diagCache[server.ID]
s.diagMu.Unlock()
if ok {
return cached.diagnostics
}
return ServerDiagnostics{ServerID: server.ID, Checks: []DiagnosticCheck{{Name: "tcp_reachable", State: diagStateUnknown, Detail: "探测请求过多,请稍后重试"}}}
}
s.diagInFlight++
s.diagMu.Unlock()
defer func() {
s.diagMu.Lock()
s.diagInFlight--
s.diagMu.Unlock()
}()
result := s.computeDiagnostics(ctx, server)
s.diagMu.Lock()
s.diagCache[server.ID] = diagnosticsCacheEntry{diagnostics: result, expiresAt: time.Now().Add(diagnosticsCacheTTL)}
s.diagMu.Unlock()
return result
}
// computeDiagnostics 执行各诊断项。定位失败、日志缺失等一律记为 unknown 或非阻断展示,
// 绝不写入 Error(Error 仅表示控制端口探测失败)。
func (s *Service) computeDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
dnsCheck := diagnoseDNS(ctx, server.ServerAddr)
tcpCheck := diagnoseTCP(ctx, server, dnsCheck)
diagnostics := ServerDiagnostics{
ServerID: server.ID,
Available: tcpCheck.State == diagStateOK,
LatencyMS: tcpCheck.LatencyMS,
Checks: []DiagnosticCheck{tcpCheck, s.diagnoseFRPCLogin(server.ID), dnsCheck},
}
if tcpCheck.State == diagStateFailed {
diagnostics.Error = tcpCheck.Detail
}
// location:非阻断展示项。缓存命中直接返回;未命中先展示 IP 占位并后台刷新,
// 不让列表页同步等待外部定位 API。
location, resolved := s.locationSnapshot(ctx, server.ServerAddr)
diagnostics.Location = location
locationCheck := DiagnosticCheck{Name: "location", State: diagStateOK, Detail: location}
if !resolved {
locationCheck.State = diagStateUnknown
locationCheck.Detail = "定位信息后台获取中"
go s.refreshLocation(server.ServerAddr)
}
diagnostics.Checks = append(diagnostics.Checks, locationCheck)
s.store.AddServerDiagnostic(ctx, diagnostics)
return diagnostics
}
// diagnoseTCP 控制端口 TCP 探测:只证明端口可达,不涉及认证与代理可用性。
func diagnoseTCP(ctx context.Context, server Server, dnsCheck DiagnosticCheck) DiagnosticCheck {
check := DiagnosticCheck{Name: "tcp_reachable"}
if dnsCheck.State == diagStateFailed {
check.State = diagStateFailed
check.Detail = "已跳过:" + dnsCheck.Detail
return check
}
dialer := net.Dialer{Timeout: diagTCPTimeout}
started := time.Now()
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(server.ServerAddr, fmt.Sprint(server.ServerPort)))
check.LatencyMS = time.Since(started).Milliseconds()
if check.LatencyMS < 1 {
check.LatencyMS = 1
}
if err != nil {
check.State = diagStateFailed
check.Detail = diagnoseDialError(err)
return check
}
_ = conn.Close()
check.State = diagStateOK
check.Detail = fmt.Sprintf("控制端口 %d 可达", server.ServerPort)
return check
}
// diagnoseDNS 记录域名解析结果:A/AAAA 地址、解析耗时;TTL 由系统解析器决定,标准库无法读取,不展示。
func diagnoseDNS(ctx context.Context, host string) DiagnosticCheck {
check := DiagnosticCheck{Name: "dns_resolution"}
if ip := net.ParseIP(host); ip != nil {
check.State = diagStateOK
check.Detail = fmt.Sprintf("配置为 IP 地址 %s,未经过域名解析", ip)
return check
}
resolver := net.DefaultResolver
ctxTimeout, cancel := context.WithTimeout(ctx, diagDNSTimeout)
defer cancel()
started := time.Now()
addresses, err := resolver.LookupIPAddr(ctxTimeout, host)
elapsed := time.Since(started).Milliseconds()
if err != nil || len(addresses) == 0 {
check.State = diagStateFailed
check.Detail = "域名解析失败:" + diagnoseResolveError(err)
return check
}
var ipv4, ipv6 []string
for _, address := range addresses {
if address.IP.To4() != nil {
ipv4 = append(ipv4, address.IP.String())
} else {
ipv6 = append(ipv6, address.IP.String())
}
}
summary := "系统解析器;A=" + strings.Join(ipv4, ", ")
if len(ipv6) > 0 {
summary += ";AAAA=" + strings.Join(ipv6, ", ")
} else {
summary += ";AAAA=无"
}
check.LatencyMS = elapsed
check.Detail = fmt.Sprintf("%s(解析耗时 %d ms)", summary, elapsed)
return check
}
func diagnoseResolveError(err error) string {
if err == nil {
return "无解析结果"
}
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
return "域名不存在"
}
return err.Error()
}
// diagnoseFRPCLogin 从 frpc 运行日志推断登录状态:ok 已登录,failed 最近一次登录失败,unknown 无证据。
func (s *Service) diagnoseFRPCLogin(serverID int64) DiagnosticCheck {
check := DiagnosticCheck{Name: "frpc_login"}
data, err := os.ReadFile(s.serverLogPath(serverID))
if os.IsNotExist(err) {
check.State = diagStateUnknown
check.Detail = "frpc 尚未运行,无登录记录"
return check
}
if err != nil {
check.State = diagStateUnknown
check.Detail = "读取 frpc 日志失败"
return check
}
state, detail := summarizeFRPCLogin(string(data))
check.State = state
check.Detail = detail
return check
}
// summarizeFRPCLogin 从日志尾部向前找最近的登录结论;登录成功优先于更早的失败记录。
func summarizeFRPCLogin(log string) (string, string) {
lines := strings.Split(strings.TrimRight(log, "\n"), "\n")
const window = 200
if len(lines) > window {
lines = lines[len(lines)-window:]
}
failureDetail := ""
for index := len(lines) - 1; index >= 0; index-- {
line := lines[index]
if prefix := strings.Index(line, "] "); prefix >= 0 {
line = line[prefix+2:]
}
if strings.Contains(line, "login to server success") {
return diagStateOK, "frpc 已登录 frps"
}
if failureDetail == "" && isLoginFailureLine(line) {
failureDetail = line
}
}
if failureDetail != "" {
if len(failureDetail) > 160 {
failureDetail = failureDetail[:160] + "…"
}
return diagStateFailed, "frpc 登录失败:" + failureDetail
}
return diagStateUnknown, "日志中暂无登录结果记录"
}
func isLoginFailureLine(line string) bool {
if strings.Contains(line, "login to the server failed") || strings.Contains(line, "connect to server error") || strings.Contains(line, "authentication failed") {
return true
}
return strings.Contains(line, "token") && strings.Contains(line, "error")
}
// locationSnapshot 返回定位缓存值;未命中时返回 IP 占位(resolved=false),由调用方后台刷新。
func (s *Service) locationSnapshot(ctx context.Context, host string) (string, bool) {
ip := resolvePublicIP(ctx, host)
if ip == "" {
// 内网地址或解析失败:展示主机本身,不发起外部定位,也不视为错误
return strings.TrimSpace(host), true
}
s.locationMu.Lock()
cached, ok := s.locations[ip]
s.locationMu.Unlock()
if ok && time.Now().Before(cached.expiresAt) {
return cached.value, true
}
return ip, false
}
// refreshLocation 后台补全定位缓存;结果只影响展示,不参与可用性判定。
func (s *Service) refreshLocation(host string) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
_ = s.serverLocation(ctx, host)
}