Files
Havline/internal/frp/route_drift.go
T
mrlee 0f68101503 feat(frp): frps vhost 回源端口支持 80/443,公网反代在该配置下不再适用
- 服务端表单不再硬性拒绝 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 生成、反代回源跟随与守卫对照
2026-10-11 15:18:32 +08:00

225 lines
7.2 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 上 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, "/")
}