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
This commit is contained in:
@@ -468,6 +468,36 @@ func (h *FRPMultiHandler) AgentRouteHealthSummary(w http.ResponseWriter, r *http
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// AgentRouteDrift 比对库里的期望与 VPS 上的实际配置,返回部署漂移清单
|
||||
func (h *FRPMultiHandler) AgentRouteDrift(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
report, err := h.svc.RouteDrift(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
// AgentRedeployRoutes 把该服务端上所有启用中的公网反代规则重新下发一遍
|
||||
func (h *FRPMultiHandler) AgentRedeployRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
}
|
||||
result, err := h.svc.RedeployAllRoutes(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -116,6 +116,8 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
||||
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/routes/redeploy", frpHandler.AgentRedeployRoutes)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
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, "/")
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package frp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProxyPassTarget(t *testing.T) {
|
||||
content := "server {\n listen 443 ssl;\n location / {\n proxy_pass http://127.0.0.1:8080;\n }\n}\n"
|
||||
if got := proxyPassTarget(content); got != "http://127.0.0.1:8080" {
|
||||
t.Fatalf("unexpected target: %q", got)
|
||||
}
|
||||
if got := proxyPassTarget("server {\n listen 80;\n}\n"); got != "" {
|
||||
t.Fatalf("expected empty target, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUpstream(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"http://127.0.0.1:8080": "127.0.0.1:8080",
|
||||
"https://127.0.0.1:8443/": "127.0.0.1:8443",
|
||||
"127.0.0.1:8080": "127.0.0.1:8080",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := normalizeUpstream(input); got != want {
|
||||
t.Fatalf("normalizeUpstream(%q) = %q,期望 %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployableProxy(t *testing.T) {
|
||||
deployable := Proxy{Enabled: true, Type: "http", CustomDomains: []string{"a.com"}}
|
||||
if !deployableProxy(deployable) {
|
||||
t.Fatal("启用的 http 规则且有自定义域名,应可部署")
|
||||
}
|
||||
for _, proxy := range []Proxy{
|
||||
{Enabled: false, Type: "http", CustomDomains: []string{"a.com"}},
|
||||
{Enabled: true, Type: "tcp", CustomDomains: []string{"a.com"}},
|
||||
{Enabled: true, Type: "http"},
|
||||
} {
|
||||
if deployableProxy(proxy) {
|
||||
t.Fatalf("不应判定为可部署:%+v", proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user