- 服务端表单不再硬性拒绝 vhostHTTPPort=80 / vhostHTTPSPort=443,改为保存时弹窗二次确认; 确认后请求携带 allow_privileged_vhost_ports 提交并由后端校验放行,未携带标记的请求(含直接调 API)仍被拒绝 - frps.toml 生成改为写入风险注释、不再报错;各规则的公网反代回源地址随 80/443 自动跟随 - 「公网反代」页检测到 80/443 后显示说明卡片并隐藏统计/Nginx/规则台账/漂移区块, 同时跳过 agent 的路由、漂移与健康请求 - 后端 DeployAgentRoute 与 RedeployAllRoutes(含漂移一键修复)拒绝下发,自动下发路径 syncAgentRoute 跳过 - 新增 internal/frp/vhost_port_test.go:校验、二次确认、frps.toml 生成、反代回源跟随与守卫对照
225 lines
7.2 KiB
Go
225 lines
7.2 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 上 havline-*.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 上 havline-*.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 上的上流地址与 Havline 记录不一致(可能被手工修改过)",
|
||
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 上存在该域名配置,但 Havline 里没有对应规则(手工添加,或已删除规则的残留)",
|
||
})
|
||
}
|
||
|
||
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) {
|
||
server, err := s.store.GetServer(ctx, serverID)
|
||
if err != nil {
|
||
return RedeployAllResult{}, err
|
||
}
|
||
// 80/443 回源端口的服务端不做任何公网反代下发(含漂移一键修复)
|
||
if privilegedVhostPorts(server.Options) {
|
||
return RedeployAllResult{}, errPrivilegedVhostNoPublicProxy()
|
||
}
|
||
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, "/")
|
||
}
|