仪表盘:新增「公网穿透」卡与运行状态「公网反代」行(新增 GET /api/frpmulti/routes/summary 逐台聚合);「性能」面板(今日异常/平均响应)与「流量 Top 5」;新增一行三卡「系统资源 / 中国 IP 段 / 通知通道」,系统资源来自新增跨平台采集 internal/sysinfo 仪表盘:「服务」卡不再写死「正常」,按 Fonu/Nginx/DDNS/证书四项实际状态聚合;/api/status 每 15 秒刷新并按页面可见性暂停轮询;删除死样式 公网反代:部署漂移清单(未部署/孤儿配置/未生效/内容不一致)+ 重新部署全部(逐条下发、单条失败不中断) 公网 Agent:端口矩阵补端口含义;frps 启动一致性核对;证书徽标补证书来源 FRP:frpc 版本探测失败时回落按二进制目录名推导,并逐台服务端探测 文档:新增 docs/agent-capability-plan.md(agent 能力扩展方案);VERSION 提升到 1.2.3
217 lines
6.8 KiB
Go
217 lines
6.8 KiB
Go
package frp
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 漂移类型:库里的期望(A)与 VPS 上的实际(B/C)之间的差异分类
|
||
const (
|
||
RouteDriftMissing = "missing" // 库里要部署但 VPS 上没有
|
||
RouteDriftOrphan = "orphan" // VPS 有但库里没有(手工添加或已删规则的残留)
|
||
RouteDriftNotLoaded = "not_loaded" // 文件在但未被 Nginx 加载
|
||
RouteDriftContentMismatch = "content_mismatch" // 上流地址与库里的期望不一致
|
||
)
|
||
|
||
// RouteDriftItem 一条差异
|
||
type RouteDriftItem struct {
|
||
Domain string `json:"domain"`
|
||
Kind string `json:"kind"`
|
||
Detail string `json:"detail"`
|
||
Expected string `json:"expected,omitempty"`
|
||
Actual string `json:"actual,omitempty"`
|
||
}
|
||
|
||
// RouteDriftReport 部署漂移报告:Expected = 库里启用中的规则域名数,Deployed = VPS 上 fonu-*.conf 数,Loaded = 其中被加载的数
|
||
type RouteDriftReport struct {
|
||
ServerID int64 `json:"server_id"`
|
||
Expected int `json:"expected"`
|
||
Deployed int `json:"deployed"`
|
||
Loaded int `json:"loaded"`
|
||
Items []RouteDriftItem `json:"items"`
|
||
CheckedAt string `json:"checked_at"`
|
||
}
|
||
|
||
// RouteDrift 比对「数据库里启用中的公网反代规则」与「VPS 上 fonu-*.conf 的实际状态」。
|
||
// 内容比对只做上流地址(proxy_pass):配置文件由 agent 渲染,应用侧不重复实现一套渲染器,
|
||
// 因此不做全文 diff,避免两套渲染逻辑漂移带来的误报。
|
||
func (s *Service) RouteDrift(ctx context.Context, serverID int64) (RouteDriftReport, error) {
|
||
client, ok := s.agentClient(ctx, serverID)
|
||
if !ok {
|
||
return RouteDriftReport{}, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||
}
|
||
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
|
||
if err != nil {
|
||
return RouteDriftReport{}, err
|
||
}
|
||
|
||
// A:库里的期望域名 → 期望上流
|
||
expected := map[string]string{}
|
||
for _, proxy := range proxies {
|
||
if !deployableProxy(proxy) {
|
||
continue
|
||
}
|
||
upstream, uerr := s.agentUpstreamFor(ctx, proxy)
|
||
if uerr != nil {
|
||
upstream = ""
|
||
}
|
||
for _, domain := range proxy.CustomDomains {
|
||
expected[domain] = upstream
|
||
}
|
||
}
|
||
|
||
details, err := client.RoutesDetail(ctx)
|
||
if err != nil {
|
||
return RouteDriftReport{}, err
|
||
}
|
||
// B / C:VPS 上的实际
|
||
actual := make(map[string]RouteDetail, len(details))
|
||
loaded := 0
|
||
for _, detail := range details {
|
||
actual[detail.Domain] = detail
|
||
if detail.Loaded {
|
||
loaded++
|
||
}
|
||
}
|
||
|
||
report := RouteDriftReport{
|
||
ServerID: serverID,
|
||
Expected: len(expected),
|
||
Deployed: len(details),
|
||
Loaded: loaded,
|
||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||
}
|
||
|
||
for domain, want := range expected {
|
||
detail, ok := actual[domain]
|
||
if !ok {
|
||
report.Items = append(report.Items, RouteDriftItem{
|
||
Domain: domain, Kind: RouteDriftMissing,
|
||
Detail: "VPS 上没有该域名的配置,重新部署即可写入",
|
||
Expected: want,
|
||
})
|
||
continue
|
||
}
|
||
if !detail.Loaded {
|
||
report.Items = append(report.Items, RouteDriftItem{
|
||
Domain: domain, Kind: RouteDriftNotLoaded,
|
||
Detail: "配置文件存在但未被 Nginx 加载(语法未通过,或 include 未覆盖该文件)",
|
||
})
|
||
continue
|
||
}
|
||
if want == "" {
|
||
continue
|
||
}
|
||
content, cerr := client.RouteConf(ctx, domain)
|
||
if cerr != nil {
|
||
continue
|
||
}
|
||
got := normalizeUpstream(proxyPassTarget(content))
|
||
if got != "" && got != normalizeUpstream(want) {
|
||
report.Items = append(report.Items, RouteDriftItem{
|
||
Domain: domain, Kind: RouteDriftContentMismatch,
|
||
Detail: "VPS 上的上流地址与 Fonu 记录不一致(可能被手工修改过)",
|
||
Expected: want, Actual: got,
|
||
})
|
||
}
|
||
}
|
||
|
||
for domain := range actual {
|
||
if _, ok := expected[domain]; ok {
|
||
continue
|
||
}
|
||
report.Items = append(report.Items, RouteDriftItem{
|
||
Domain: domain, Kind: RouteDriftOrphan,
|
||
Detail: "VPS 上存在该域名配置,但 Fonu 里没有对应规则(手工添加,或已删除规则的残留)",
|
||
})
|
||
}
|
||
|
||
sort.Slice(report.Items, func(i, j int) bool {
|
||
if report.Items[i].Kind != report.Items[j].Kind {
|
||
return report.Items[i].Kind < report.Items[j].Kind
|
||
}
|
||
return report.Items[i].Domain < report.Items[j].Domain
|
||
})
|
||
return report, nil
|
||
}
|
||
|
||
// RedeployRouteResult 单条规则的重新部署结果
|
||
type RedeployRouteResult struct {
|
||
ProxyID int64 `json:"proxy_id"`
|
||
Name string `json:"name"`
|
||
Domains string `json:"domains"`
|
||
OK bool `json:"ok"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// RedeployAllResult 全量重部署汇总
|
||
type RedeployAllResult struct {
|
||
ServerID int64 `json:"server_id"`
|
||
Total int `json:"total"`
|
||
Succeeded int `json:"succeeded"`
|
||
Failed int `json:"failed"`
|
||
Results []RedeployRouteResult `json:"results"`
|
||
}
|
||
|
||
// RedeployAllRoutes 把该服务端上所有启用中的公网反代规则重新下发一遍。
|
||
// 逐条执行、单条失败不中断(沿用 Reload 的隔离报错风格),返回逐条结果供前端汇总展示。
|
||
func (s *Service) RedeployAllRoutes(ctx context.Context, serverID int64) (RedeployAllResult, error) {
|
||
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
|
||
if err != nil {
|
||
return RedeployAllResult{}, err
|
||
}
|
||
result := RedeployAllResult{ServerID: serverID, Results: []RedeployRouteResult{}}
|
||
for _, proxy := range proxies {
|
||
if !deployableProxy(proxy) {
|
||
continue
|
||
}
|
||
result.Total++
|
||
item := RedeployRouteResult{
|
||
ProxyID: proxy.ID,
|
||
Name: proxy.Name,
|
||
Domains: strings.Join(proxy.CustomDomains, "、"),
|
||
}
|
||
if err := s.DeployAgentRoute(ctx, proxy.ID); err != nil {
|
||
item.Error = err.Error()
|
||
result.Failed++
|
||
} else {
|
||
item.OK = true
|
||
result.Succeeded++
|
||
}
|
||
result.Results = append(result.Results, item)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// deployableProxy 与 DeployAgentRoute 的准入条件保持一致:启用的 http/https 规则且填了自定义域名
|
||
func deployableProxy(proxy Proxy) bool {
|
||
if !proxy.Enabled || len(proxy.CustomDomains) == 0 {
|
||
return false
|
||
}
|
||
return proxy.Type == "http" || proxy.Type == "https"
|
||
}
|
||
|
||
// proxyPassTarget 从 Nginx 配置文本里取出第一条 proxy_pass 的目标地址
|
||
func proxyPassTarget(content string) string {
|
||
for _, line := range strings.Split(content, "\n") {
|
||
trimmed := strings.TrimSpace(line)
|
||
if !strings.HasPrefix(trimmed, "proxy_pass ") {
|
||
continue
|
||
}
|
||
value := strings.TrimPrefix(trimmed, "proxy_pass ")
|
||
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(value), ";"))
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// normalizeUpstream 去掉协议前缀与结尾斜杠,便于比较(agent 渲染的是 http://127.0.0.1:8080,应用侧期望的是 127.0.0.1:8080)
|
||
func normalizeUpstream(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
value = strings.TrimPrefix(value, "http://")
|
||
value = strings.TrimPrefix(value, "https://")
|
||
return strings.TrimSuffix(value, "/")
|
||
}
|