- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
252 lines
8.3 KiB
Go
252 lines
8.3 KiB
Go
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)
|
||
}
|