Files
Havline/internal/frp/route_drift.go
T
mrlee f75e672a45 feat: 公网反代部署漂移与全量重部署,仪表盘补公网穿透/系统资源/中国 IP 段/通知通道(v1.2.3)
仪表盘:新增「公网穿透」卡与运行状态「公网反代」行(新增 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
2026-09-24 09:20:07 +08:00

217 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "/")
}