Files
-Fonu/internal/frp/traffic.go
T

227 lines
8.5 KiB
Go
Raw Normal View History

2026-09-23 09:40:59 +08:00
package frp
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// frps 管理接口(webServer)返回的代理状态结构,仅取本模块用到的字段。
type frpsProxyStatus struct {
Name string `json:"name"`
Type string `json:"type"`
TodayTrafficIn int64 `json:"today_traffic_in"`
TodayTrafficOut int64 `json:"today_traffic_out"`
CurrentConns int `json:"cur_conns"`
}
type ProxyTraffic struct {
TrafficIn int64 `json:"traffic_in"`
TrafficOut int64 `json:"traffic_out"`
CurConns int `json:"cur_conns"`
TrafficInRate int64 `json:"traffic_in_rate"`
TrafficOutRate int64 `json:"traffic_out_rate"`
}
type ServerTraffic struct {
ServerID int64 `json:"server_id"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Proxies map[string]ProxyTraffic `json:"proxies"`
ProxyNamesByDomain map[string]string `json:"proxy_names_by_domain,omitempty"`
}
type ProxiesTraffic struct {
Items map[int64]ProxyTraffic `json:"items"`
Errors map[int64]string `json:"errors,omitempty"`
SampledAt string `json:"sampled_at"`
}
type trafficSample struct {
at time.Time
in int64
out int64
}
type frpsTrafficClient struct {
httpClient *http.Client
}
func newFRPSTrafficClient() *frpsTrafficClient {
return &frpsTrafficClient{httpClient: &http.Client{Timeout: 6 * time.Second}}
}
// fetchServerTraffic 版本自适应采集:
// - frp >= v0.71.0:GET /api/v2/proxies(一次返回全部类型,data.items[].name 含 user 前缀);
// - frp < v0.71.0(如 v0.68.0):无 V2 API(/api/v2/* 404),用 GET /api/proxies(一次返回全部类型)。
//
// 判定方式:V2 返回 404 page not found 即回退旧端点;其余错误如实返回。
func (c *frpsTrafficClient) fetchServerTraffic(ctx context.Context, server Server, password string) ServerTraffic {
empty := map[string]ProxyTraffic{}
if strings.TrimSpace(server.Options.DashboardAddr) == "" || server.Options.DashboardPort == 0 {
return ServerTraffic{ServerID: server.ID, Error: "未配置 frps 管理接口(webServer)", Proxies: empty}
}
// 优先 V2 API(v0.71.0+)
v2URL := fmt.Sprintf("http://%s:%d/api/v2/proxies", server.Options.DashboardAddr, server.Options.DashboardPort)
body, status, err := c.getJSON(ctx, v2URL, server, password)
if err != nil {
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口请求失败(GET %s): %v", v2URL, err), Proxies: empty}
}
if status == http.StatusOK {
return c.parseV2Proxies(server, body)
}
if status != http.StatusNotFound {
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口返回 %d(GET %s): %s", status, v2URL, strings.TrimSpace(string(body))), Proxies: empty}
}
// 旧版没有全量列表端点,流量采集无法在不知道代理名时继续;公网反代健康检查使用
// fetchLegacyRouteProxies 按候选规则逐条查询 /api/proxies/{user.name}。
return ServerTraffic{ServerID: server.ID, Error: "frps 旧版 dashboard 不支持全量代理列表,请通过规则名逐条查询", Proxies: empty}
}
// legacyProxyInfo 是 frp 0.68.0 的单代理响应结构。
type legacyProxyInfo struct {
Name string `json:"name"`
User string `json:"user"`
Conf struct {
CustomDomains []string `json:"customDomains"`
} `json:"conf"`
Status string `json:"status"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int `json:"curConns"`
}
// fetchLegacyProxyByName 兼容 frp 0.68.0:旧 dashboard 只能按实际代理名查询,
// 代理名遵循 user.proxyName 规则(例如 admin.ssssss)。
func (c *frpsTrafficClient) fetchLegacyProxyByName(ctx context.Context, server Server, password, proxyName string) (legacyProxyInfo, int, error) {
url := fmt.Sprintf("http://%s:%d/api/proxies/%s", server.Options.DashboardAddr, server.Options.DashboardPort, proxyName)
body, status, err := c.getJSON(ctx, url, server, password)
if err != nil {
return legacyProxyInfo{}, 0, err
}
if status != http.StatusOK {
return legacyProxyInfo{}, status, fmt.Errorf("GET %s 返回 %d: %s", url, status, strings.TrimSpace(string(body)))
}
var info legacyProxyInfo
if err := json.Unmarshal(body, &info); err != nil {
return legacyProxyInfo{}, status, fmt.Errorf("解析 GET %s 响应失败: %v", url, err)
}
return info, status, nil
}
// fetchLegacyRouteProxies 按候选规则逐条查询旧版 frps,并保留域名到真实代理名的映射。
func (c *frpsTrafficClient) fetchLegacyRouteProxies(ctx context.Context, server Server, password string, names []string) ServerTraffic {
empty := map[string]ProxyTraffic{}
proxies := make(map[string]ProxyTraffic)
proxyNamesByDomain := make(map[string]string)
var lastErr error
for _, name := range names {
proxyName := name
if user := strings.TrimSpace(server.Options.User); user != "" && !strings.HasPrefix(proxyName, user+".") {
proxyName = user + "." + proxyName
}
info, _, err := c.fetchLegacyProxyByName(ctx, server, password, proxyName)
if err != nil {
lastErr = err
continue
}
traffic := ProxyTraffic{TrafficIn: info.TodayTrafficIn, TrafficOut: info.TodayTrafficOut, CurConns: info.CurConns}
proxies[info.Name] = traffic
for _, domain := range info.Conf.CustomDomains {
if domain = strings.ToLower(strings.TrimSpace(domain)); domain != "" {
proxies["domain:"+domain] = traffic
proxyNamesByDomain[domain] = info.Name
}
}
}
if len(proxies) == 0 && lastErr != nil {
return ServerTraffic{ServerID: server.ID, Error: lastErr.Error(), Proxies: empty}
}
return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
}
// getJSON 执行带 Basic Auth 的 GET 请求并返回响应体与状态码。
func (c *frpsTrafficClient) getJSON(ctx context.Context, url string, server Server, password string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
req.SetBasicAuth(user, password)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, resp.StatusCode, err
}
return body, resp.StatusCode, nil
}
// parseV2Proxies 解析 V2 API 响应(实测结构):
// {"code":200,"data":{"items":[{"name":"admin.ssssss","user":"admin","spec":{"type":"http","http":{"customDomains":[...]}},"status":{"phase":"online",...}}]}}
func (c *frpsTrafficClient) parseV2Proxies(server Server, body []byte) ServerTraffic {
empty := map[string]ProxyTraffic{}
var payload struct {
Data struct {
Items []struct {
Name string `json:"name"`
User string `json:"user"`
Spec struct {
Type string `json:"type"`
HTTP struct {
CustomDomains []string `json:"customDomains"`
} `json:"http"`
} `json:"spec"`
Status struct {
Phase string `json:"phase"`
TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int `json:"curConns"`
} `json:"status"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("解析 V2 响应失败: %v", err), Proxies: empty}
}
proxies := make(map[string]ProxyTraffic)
proxyNamesByDomain := make(map[string]string)
for _, item := range payload.Data.Items {
traffic := ProxyTraffic{
TrafficIn: item.Status.TodayTrafficIn,
TrafficOut: item.Status.TodayTrafficOut,
CurConns: item.Status.CurConns,
}
proxies[item.Name] = traffic
// 域名索引:健康检查按域名直接匹配(比代理名猜测可靠)
for _, d := range item.Spec.HTTP.CustomDomains {
domain := strings.ToLower(strings.TrimSpace(d))
if domain != "" {
proxies["domain:"+domain] = traffic
proxyNamesByDomain[domain] = item.Name
}
}
}
return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
}
// validDashboardAddr 宽松校验管理接口地址(IP 或域名,不带协议与端口)。
func validDashboardAddr(addr string) bool {
addr = strings.TrimSpace(addr)
if addr == "" {
return true
}
return validHost(addr)
}