feat: create standalone native fnOS repository
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user