From aafff522d8ca8b4b5c252fb5db51a1de39bf5c83 Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Sun, 11 Oct 2026 15:07:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(frp):=20frps=20vhost=20=E5=9B=9E=E6=BA=90?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E6=94=AF=E6=8C=81=2080/443=EF=BC=8C=E5=85=AC?= =?UTF-8?q?=E7=BD=91=E5=8F=8D=E4=BB=A3=E5=9C=A8=E8=AF=A5=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8B=E4=B8=8D=E5=86=8D=E9=80=82=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端表单不再硬性拒绝 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 生成、反代回源跟随与守卫对照 --- internal/api/frpmulti_handler.go | 5 +- internal/frp/frps_template.go | 3 +- internal/frp/model.go | 4 + internal/frp/route_drift.go | 8 ++ internal/frp/service.go | 22 ++- internal/frp/validate.go | 21 ++- internal/frp/validate_test.go | 9 +- internal/frp/vhost_port_test.go | 232 ++++++++++++++++++++++++++++++ web/src/api/types.ts | 2 + web/src/views/AgentView.vue | 42 +++++- web/src/views/ProxyRemoteView.vue | 44 +++++- 11 files changed, 370 insertions(+), 22 deletions(-) create mode 100644 internal/frp/vhost_port_test.go diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index beb0026..f84d1f6 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -50,7 +50,9 @@ type frpServerRequest struct { TLSTrustedCA string `json:"tls_trusted_ca"` ClearTLSCertificate bool `json:"clear_tls_certificate"` ClearTLSKey bool `json:"clear_tls_key"` - ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"` + // 二次确认标记:vhost 回源端口填 80/443 时前端必须带上 + AllowPrivilegedVhostPorts bool `json:"allow_privileged_vhost_ports"` + ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"` } type frpProxyRequest struct { @@ -1061,6 +1063,7 @@ func (request frpServerRequest) serverInput() frp.ServerInput { DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword, TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA, ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA, + AllowPrivilegedVhostPorts: request.AllowPrivilegedVhostPorts, } } diff --git a/internal/frp/frps_template.go b/internal/frp/frps_template.go index 250c68c..ace8c27 100644 --- a/internal/frp/frps_template.go +++ b/internal/frp/frps_template.go @@ -66,7 +66,8 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str httpsPort = 8443 } if httpPort == 80 || httpsPort == 443 { - return "", fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请在服务端表单改为 8080/8443 后重新生成配置") + // 已由前端二次确认;这里只把后果写进生成的文件,便于日后在 VPS 上直接看懂 + b.WriteString("# ⚠️ 回源端口使用 80/443:请确认 VPS 上没有 Nginx 等其他进程占用,否则 frps 会因端口冲突启动失败\n") } b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort)) b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort)) diff --git a/internal/frp/model.go b/internal/frp/model.go index c7960ea..7052d5b 100644 --- a/internal/frp/model.go +++ b/internal/frp/model.go @@ -241,6 +241,10 @@ type ServerInput struct { ClearTLSCertificate bool ClearTLSKey bool ClearTLSTrustedCA bool + + // AllowPrivilegedVhostPorts 是本次请求的二次确认标记:vhost 回源端口填 80/443 时必须为 true。 + // 只作用于本次校验,不落库。 + AllowPrivilegedVhostPorts bool } type ProxyInput struct { diff --git a/internal/frp/route_drift.go b/internal/frp/route_drift.go index 4b44e9e..d76100b 100644 --- a/internal/frp/route_drift.go +++ b/internal/frp/route_drift.go @@ -159,6 +159,14 @@ type RedeployAllResult struct { // 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 diff --git a/internal/frp/service.go b/internal/frp/service.go index 643f3ac..28a84da 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -1502,6 +1502,12 @@ func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) err return client.PushFRPSConfig(ctx, content) } +// errPrivilegedVhostNoPublicProxy 说明:frps 回源端口落在 80/443 时,VPS 上由 frps 直接对外, +// 没有 Nginx 的位置,公网反代(路由下发)整体不适用。 +func errPrivilegedVhostNoPublicProxy() error { + return fmt.Errorf("该服务端的 frps 回源端口为 80/443:VPS 上由 frps 直接对外,公网反代(Nginx)不适用也无需部署;如需公网反代,请到「公网服务端」页把回源端口改回 8080/8443") +} + // agentUpstreamFor 返回该规则反代时的回源地址(frps vhost 端口),端口来自服务端表单自定义, // 未配置时用默认 8080/8443,与 frps.toml 生成器同源。 func (s *Service) agentUpstreamFor(ctx context.Context, proxy Proxy) (string, error) { @@ -1840,6 +1846,14 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error { if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 { return fmt.Errorf("只有启用的 HTTP/HTTPS 规则可以部署公网反代") } + server, serr := s.store.GetServer(ctx, proxy.ServerID) + if serr != nil { + return serr + } + // 80/443 回源端口的服务端由 frps 直接对外,VPS 上没有 Nginx 的位置,直接拒绝部署 + if privilegedVhostPorts(server.Options) { + return errPrivilegedVhostNoPublicProxy() + } client, ok := s.agentClient(ctx, proxy.ServerID) if !ok { return fmt.Errorf("公网 agent 未配置或 Token 未保存") @@ -1855,10 +1869,6 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error { return uerr } // vhost 端口供 agent 回源自检用 - server, serr := s.store.GetServer(ctx, proxy.ServerID) - if serr != nil { - return serr - } vhostPort := server.Options.VhostHTTPPort if vhostPort <= 0 { vhostPort = 8080 @@ -2116,6 +2126,10 @@ func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) { if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 { return } + // 80/443 回源端口的服务端由 frps 直接对外:任何自动下发都要跳过 + if server, err := s.store.GetServer(ctx, proxy.ServerID); err == nil && privilegedVhostPorts(server.Options) { + return + } client, ok := s.agentClient(ctx, proxy.ServerID) if !ok { return diff --git a/internal/frp/validate.go b/internal/frp/validate.go index 4c1e912..82b5b03 100644 --- a/internal/frp/validate.go +++ b/internal/frp/validate.go @@ -30,8 +30,10 @@ func validateServerInput(in ServerInput) error { if in.Options.VhostHTTPSPort != 0 && !validPort(in.Options.VhostHTTPSPort) { return fmt.Errorf("frps HTTPS 回源端口无效") } - if in.Options.VhostHTTPPort == 80 || in.Options.VhostHTTPSPort == 443 { - return fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请使用 8080/8443") + // 80/443 默认由 VPS 上的 Nginx 占用,frps 再监听会因端口冲突启动失败; + // 仍允许使用(例如 VPS 上不跑 Nginx、由 frps 直接对外),但必须由前端二次确认后带标记提交。 + if (in.Options.VhostHTTPPort == 80 || in.Options.VhostHTTPSPort == 443) && !in.AllowPrivilegedVhostPorts { + return fmt.Errorf("frps vhost 回源端口 80/443 通常由 VPS 上的 Nginx 占用;确认该端口已释放后,请确认风险提示再保存") } if !validDashboardAddr(in.Options.DashboardAddr) { return fmt.Errorf("frps 管理接口地址无效") @@ -366,6 +368,21 @@ func validPort(port int) bool { return port > 0 && port <= 65535 } +// privilegedVhostPorts 判断 frps 回源端口是否落在 80/443(未配置时按默认 8080/8443 计)。 +// 这种拓扑下 VPS 上由 frps 直接对外,没有 Nginx 监听 80/443 的位置, +// 公网反代(Nginx 路由下发)既不适用、也不应该部署。 +func privilegedVhostPorts(options ServerOptions) bool { + httpPort := options.VhostHTTPPort + if httpPort <= 0 { + httpPort = 8080 + } + httpsPort := options.VhostHTTPSPort + if httpsPort <= 0 { + httpsPort = 8443 + } + return httpPort == 80 || httpsPort == 443 +} + func validHost(host string) bool { host = strings.TrimSpace(host) if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") { diff --git a/internal/frp/validate_test.go b/internal/frp/validate_test.go index 26a91c8..d0d91b0 100644 --- a/internal/frp/validate_test.go +++ b/internal/frp/validate_test.go @@ -57,17 +57,22 @@ func TestValidateServerInputVhostPorts(t *testing.T) { name string http int https int + confirm bool wantErr bool }{ {name: "默认端口", http: 8080, https: 8443}, - {name: "HTTP 80 与 Nginx 冲突", http: 80, https: 8443, wantErr: true}, - {name: "HTTPS 443 与 Nginx 冲突", http: 8080, https: 443, wantErr: true}, + {name: "HTTP 80 未二次确认", http: 80, https: 8443, wantErr: true}, + {name: "HTTPS 443 未二次确认", http: 8080, https: 443, wantErr: true}, + {name: "HTTP 80 已二次确认", http: 80, https: 8443, confirm: true}, + {name: "HTTPS 443 已二次确认", http: 8080, https: 443, confirm: true}, + {name: "80 与 443 同时使用且已二次确认", http: 80, https: 443, confirm: true}, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { input := normalizeServerInput(base) input.Options.VhostHTTPPort = testCase.http input.Options.VhostHTTPSPort = testCase.https + input.AllowPrivilegedVhostPorts = testCase.confirm err := validateServerInput(input) if (err != nil) != testCase.wantErr { t.Fatalf("validateServerInput() error = %v, wantErr %v", err, testCase.wantErr) diff --git a/internal/frp/vhost_port_test.go b/internal/frp/vhost_port_test.go new file mode 100644 index 0000000..fa62fbf --- /dev/null +++ b/internal/frp/vhost_port_test.go @@ -0,0 +1,232 @@ +package frp + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "strings" + "testing" + + "github.com/havline/havline/internal/certificate" + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/db" + "github.com/havline/havline/internal/secret" +) + +// newVhostPortTestService 建一个带真实 SQLite 的 Service:80/443 的放行是「校验 + 生成 frps.toml」 +// 两段链路共同决定的,纯函数测试覆盖不到。 +func newVhostPortTestService(t *testing.T) (*Service, context.Context) { + t.Helper() + dir := t.TempDir() + conn, err := db.Open(filepath.Join(dir, "havline.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil { + t.Fatalf("migrate: %v", err) + } + box, err := secret.NewBox("vhost-port-test-key") + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, logger, certificate.NewStore(conn)) + if err := svc.EnsureDirs(); err != nil { + t.Fatalf("ensure dirs: %v", err) + } + return svc, context.Background() +} + +func baseVhostServerInput() ServerInput { + return ServerInput{ + Name: "test-vps", + ServerAddr: "10.0.0.1", + ServerPort: 7000, + Enabled: true, + Token: "test-token", + Options: ServerOptions{AuthMethod: "token", LogLevel: "info", Protocol: "tcp"}, + } +} + +// 未二次确认时,80/443 必须被拒绝。 +func TestCreateServerPrivilegedVhostPortsRequiresConfirmation(t *testing.T) { + svc, ctx := newVhostPortTestService(t) + + for _, testCase := range []struct { + name string + http int + https int + }{ + {name: "HTTP 80", http: 80, https: 8443}, + {name: "HTTPS 443", http: 8080, https: 443}, + } { + t.Run(testCase.name, func(t *testing.T) { + in := baseVhostServerInput() + in.Options.VhostHTTPPort = testCase.http + in.Options.VhostHTTPSPort = testCase.https + if _, err := svc.CreateServer(ctx, in); err == nil { + t.Fatal("未二次确认时保存 80/443 应被拒绝") + } else if !strings.Contains(err.Error(), "80/443") { + t.Fatalf("错误信息应点明 80/443:%v", err) + } + }) + } +} + +// 二次确认后允许保存,生成的 frps.toml 要带风险注释且不能报错。 +func TestCreateServerPrivilegedVhostPortsConfirmed(t *testing.T) { + svc, ctx := newVhostPortTestService(t) + + in := baseVhostServerInput() + in.Options.VhostHTTPPort = 80 + in.Options.VhostHTTPSPort = 443 + in.AllowPrivilegedVhostPorts = true + server, err := svc.CreateServer(ctx, in) + if err != nil { + t.Fatalf("二次确认后保存应成功:%v", err) + } + + // vhost 端口只在存在 http/https/tcpmux 规则时才写入 frps.toml + if _, err := svc.CreateProxy(ctx, ProxyInput{ + ServerID: server.ID, Name: "nas-web", Type: "http", + LocalIP: "127.0.0.1", LocalPort: 18080, + CustomDomains: []string{"nas.example.com"}, Enabled: true, + }); err != nil { + t.Fatalf("创建 http 规则失败:%v", err) + } + + content, err := svc.buildFRPSServerConfig(ctx, server) + if err != nil { + t.Fatalf("已放行的 80/443 不应导致 frps.toml 生成失败:%v", err) + } + for _, want := range []string{"vhostHTTPPort = 80", "vhostHTTPSPort = 443", "80/443"} { + if !strings.Contains(content, want) { + t.Fatalf("frps.toml 缺少 %q:\n%s", want, content) + } + } +} + +// 反代回源地址必须跟着 80/443 走,否则 VPS 上 Nginx 会指向错端口。 +func TestAgentUpstreamFollowsPrivilegedVhostPorts(t *testing.T) { + svc, ctx := newVhostPortTestService(t) + + in := baseVhostServerInput() + in.Options.VhostHTTPPort = 80 + in.Options.VhostHTTPSPort = 443 + in.AllowPrivilegedVhostPorts = true + server, err := svc.CreateServer(ctx, in) + if err != nil { + t.Fatalf("保存失败:%v", err) + } + + httpProxy, err := svc.CreateProxy(ctx, ProxyInput{ + ServerID: server.ID, Name: "nas-web", Type: "http", + LocalIP: "127.0.0.1", LocalPort: 18080, + CustomDomains: []string{"nas.example.com"}, Enabled: true, + }) + if err != nil { + t.Fatalf("创建 http 规则失败:%v", err) + } + if got, err := svc.agentUpstreamFor(ctx, httpProxy); err != nil { + t.Fatalf("解析回源地址失败:%v", err) + } else if got != "127.0.0.1:80" { + t.Fatalf("HTTP 回源地址 = %q,期望 127.0.0.1:80", got) + } + + httpsProxy, err := svc.CreateProxy(ctx, ProxyInput{ + ServerID: server.ID, Name: "nas-web-tls", Type: "https", + LocalIP: "127.0.0.1", LocalPort: 18080, + CustomDomains: []string{"nas-tls.example.com"}, Enabled: true, + }) + if err != nil { + t.Fatalf("创建 https 规则失败:%v", err) + } + if got, err := svc.agentUpstreamFor(ctx, httpsProxy); err != nil { + t.Fatalf("解析回源地址失败:%v", err) + } else if got != "127.0.0.1:443" { + t.Fatalf("HTTPS 回源地址 = %q,期望 127.0.0.1:443", got) + } +} + +func TestPrivilegedVhostPortsDetection(t *testing.T) { + cases := []struct { + name string + options ServerOptions + want bool + }{ + {name: "默认 8080/8443", options: ServerOptions{}, want: false}, + {name: "留空但显式 0", options: ServerOptions{VhostHTTPPort: 0, VhostHTTPSPort: 0}, want: false}, + {name: "HTTP 80", options: ServerOptions{VhostHTTPPort: 80}, want: true}, + {name: "HTTPS 443", options: ServerOptions{VhostHTTPSPort: 443}, want: true}, + {name: "自定义高位端口", options: ServerOptions{VhostHTTPPort: 18080, VhostHTTPSPort: 18443}, want: false}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := privilegedVhostPorts(testCase.options); got != testCase.want { + t.Fatalf("privilegedVhostPorts() = %v, 期望 %v", got, testCase.want) + } + }) + } +} + +// 80/443 回源端口下公网反代不适用:单条部署与全量重部署(含漂移一键修复)都必须被拒绝。 +func TestPublicReverseProxyRejectedForPrivilegedVhostPorts(t *testing.T) { + svc, ctx := newVhostPortTestService(t) + + in := baseVhostServerInput() + in.Options.VhostHTTPPort = 80 + in.Options.VhostHTTPSPort = 443 + in.AllowPrivilegedVhostPorts = true + server, err := svc.CreateServer(ctx, in) + if err != nil { + t.Fatalf("保存失败:%v", err) + } + proxy, err := svc.CreateProxy(ctx, ProxyInput{ + ServerID: server.ID, Name: "nas-web", Type: "http", + LocalIP: "127.0.0.1", LocalPort: 18080, + CustomDomains: []string{"nas.example.com"}, Enabled: true, + }) + if err != nil { + t.Fatalf("创建 http 规则失败:%v", err) + } + + if err := svc.DeployAgentRoute(ctx, proxy.ID); err == nil { + t.Fatal("80/443 回源端口下部署公网反代应被拒绝") + } else if !strings.Contains(err.Error(), "80/443") { + t.Fatalf("错误信息应说明 80/443 不适用公网反代:%v", err) + } + + if _, err := svc.RedeployAllRoutes(ctx, server.ID); err == nil { + t.Fatal("80/443 回源端口下全量重部署应被拒绝") + } else if !strings.Contains(err.Error(), "80/443") { + t.Fatalf("错误信息应说明 80/443 不适用公网反代:%v", err) + } +} + +// 对照组:默认 8080/8443 时不应被 80/443 规则拦下(此时失败是「没配 agent」,属于另一条链路)。 +func TestPublicReverseProxyNotBlockedForDefaultVhostPorts(t *testing.T) { + svc, ctx := newVhostPortTestService(t) + + server, err := svc.CreateServer(ctx, baseVhostServerInput()) + if err != nil { + t.Fatalf("保存失败:%v", err) + } + proxy, err := svc.CreateProxy(ctx, ProxyInput{ + ServerID: server.ID, Name: "nas-web", Type: "http", + LocalIP: "127.0.0.1", LocalPort: 18080, + CustomDomains: []string{"nas.example.com"}, Enabled: true, + }) + if err != nil { + t.Fatalf("创建 http 规则失败:%v", err) + } + + err = svc.DeployAgentRoute(ctx, proxy.ID) + if err == nil { + t.Fatal("未配置 agent 时部署应失败") + } + if strings.Contains(err.Error(), "不适用也无需部署") { + t.Fatalf("默认端口不应被 80/443 规则拦下,实际:%v", err) + } +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 876735c..cd5e477 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -676,6 +676,8 @@ export interface FrpServerPayload { clear_tls_certificate?: boolean clear_tls_key?: boolean clear_tls_trusted_ca?: boolean + /** 二次确认标记:vhost 回源端口使用 80/443 时必须为 true(前端弹窗确认后置位)*/ + allow_privileged_vhost_ports?: boolean } export interface FrpAgentRouteCandidate { diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index deb6539..e657b35 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -492,7 +492,7 @@ :show-icon="false" style="margin-bottom: 12px" > - 80/443 通常由 VPS 上的 Nginx 占用,frps 不能再次监听;请改为 8080/8443,否则 frps 会因端口冲突启动失败。 + 80/443 通常由 VPS 上的 Nginx 占用。继续使用前请先停止占用该端口的服务,保存时会再次弹窗确认;端口仍被占用时 frps 会因冲突启动失败。若 VPS 上还要保留 Nginx,请改用 8080/8443。
@@ -1264,6 +1264,8 @@ const serverStartingId = ref(null) const agentActionId = ref(null) const measuringId = ref(null) const savingServer = ref(false) +// 80/443 回源端口的二次确认结果,按弹窗会话记录,避免每次保存都重复弹窗 +const confirmedPrivilegedVhost = ref('') const showServerModal = ref(false) @@ -1356,7 +1358,7 @@ const serverHelpItems = computed(() => { { title: '连接调优', text: '心跳与 TCP 多路复用保持默认即可;网络不稳定时可加大超时,连接池可降低高并发时的建连延迟。' }, ] if (serverFormTab.value === 'server') return [ - { title: 'frps vhost 与域名反代', text: 'vhostHTTPPort/vhostHTTPSPort 是 Nginx 反代的回源端口,同时决定 subDomainHost、tcpmux 等域名入口;留空用默认 8080/8443,80/443 会与 VPS 上的 Nginx 冲突。修改后需「下发 frps 配置」并重启 frps,再重新部署各规则。' }, + { title: 'frps vhost 与域名反代', text: 'vhostHTTPPort/vhostHTTPSPort 是 Nginx 反代的回源端口,同时决定 subDomainHost、tcpmux 等域名入口;留空用默认 8080/8443。80/443 通常与 VPS 上的 Nginx 冲突,确需使用时保存前会二次确认,请先释放该端口。修改后需「下发 frps 配置」并重启 frps,再重新部署各规则。' }, { title: 'TCP/UDP 端口分配', text: 'allowPorts 只限制 tcp/udp 规则使用的 remotePort,不控制 vhost 回源端口;实际端口仍需在云安全组与 VPS 主机防火墙放行。' }, { title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Havline 借此拉取穿透规则的实时连接与流量;地址与端口需成对填写,与连接认证无关。' }, ] @@ -1463,6 +1465,7 @@ function resetServerForm() { function openServerModal(server?: FrpServer) { editingServer.value = server ?? null serverFormTab.value = 'basic' + confirmedPrivilegedVhost.value = '' resetServerForm() if (server) { Object.assign(serverForm, { ...server, token: '', oidc_client_secret: '', agent_token: '', ssh_secret: '' }) @@ -1671,10 +1674,7 @@ function validateServerForm(): string { if (!editingServer.value && !form.oidc_client_secret.trim()) return 'OIDC 需填写客户端密钥' } - if (form.options.vhost_http_port === 80 || form.options.vhost_https_port === 443) { - return 'frps 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用),请改为 8080/8443' - } - + // 80/443 不再硬拦:由 saveServer 弹窗二次确认,后端要求同一次请求带上确认标记 const ports: Array<[string, number]> = [ ['控制端口', form.server_port], ['HTTP 回源端口', form.options.vhost_http_port || 8080], @@ -1708,12 +1708,41 @@ function validateServerForm(): string { return '' } +// 当前表单里用到的 80/443 回源端口;空数组表示无需确认。 +function privilegedVhostPorts(): number[] { + const ports: number[] = [] + if (serverForm.options.vhost_http_port === 80) ports.push(80) + if (serverForm.options.vhost_https_port === 443) ports.push(443) + return ports +} + +// 80/443 通常已被 VPS 上的 Nginx 占用,放行前必须让用户确认自己已经释放了端口。 +function confirmPrivilegedVhostPorts(ports: number[]): Promise { + return new Promise((resolve) => { + dialog.warning({ + title: `确认使用 ${ports.join(' / ')} 作为 frps 回源端口`, + content: `${ports.join(' / ')} 通常已被 VPS 上的 Nginx 占用。继续前请先停止占用该端口的服务,否则 frps 会因端口冲突启动失败,隧道和公网反代都会不可用。保存后需在服务端详情「下发 frps 配置」并重启 frps 才生效。`, + positiveText: '端口已释放,继续保存', + negativeText: '取消', + onPositiveClick: () => resolve(true), + onNegativeClick: () => resolve(false), + onClose: () => resolve(false), + }) + }) +} + async function saveServer() { const invalid = validateServerForm() if (invalid) { message.error(invalid) return } + const privileged = privilegedVhostPorts() + if (privileged.length > 0 && confirmedPrivilegedVhost.value !== privileged.join(',')) { + const confirmed = await confirmPrivilegedVhostPorts(privileged) + if (!confirmed) return + confirmedPrivilegedVhost.value = privileged.join(',') + } if (serverForm.mgmt_enabled && !serverForm.agent_url.trim()) { message.warning('未填写 Agent 地址:保存后可在本页通过 SSH 安装 Agent 自动回填') } @@ -1730,6 +1759,7 @@ async function saveServer() { tls_certificate: serverForm.tls_certificate, tls_key: serverForm.tls_key, tls_trusted_ca: serverForm.tls_trusted_ca, clear_tls_certificate: serverForm.clear_tls_certificate, clear_tls_key: serverForm.clear_tls_key, clear_tls_trusted_ca: serverForm.clear_tls_trusted_ca, + allow_privileged_vhost_ports: privileged.length > 0, } if (editingServer.value) await api.updateFrpServer(editingServer.value.id, payload) else await api.createFrpServer(payload) diff --git a/web/src/views/ProxyRemoteView.vue b/web/src/views/ProxyRemoteView.vue index e6d8e11..e37f28d 100644 --- a/web/src/views/ProxyRemoteView.vue +++ b/web/src/views/ProxyRemoteView.vue @@ -61,8 +61,18 @@ {{ loadErrorMsg }} + + +
+ 该服务端的 frps 回源端口是 {{ privilegedVhostPorts.join(' / ') }},公网反代不适用 + 80/443 已由 frps 直接对外使用,VPS 上没有 Nginx 的位置:域名访问走 FRP 隧道直达 NAS,不需要再部署 Nginx 反代,本页也不会自动下发任何路由。 + 如需使用公网反代,请到「公网服务端」页把回源端口改回 8080/8443,再回本页部署。 + 若这台 VPS 上还留着以前部署的 Nginx 路由,需先把回源端口改回 8080/8443,才能在本页移除。 +
+
+ - +
检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }}) @@ -81,7 +91,7 @@ - +
{{ nginxBlocking.title }} @@ -100,7 +110,7 @@ -
+
Agent 与 frps 的安装、升级、启停、配置下发在「公网服务端」页;本页只负责把规则发布到 Nginx @@ -108,7 +118,7 @@
-
+
- +
@@ -170,7 +180,7 @@ - +

穿透规则保存后不会自动改 VPS:点「部署反代」才会发布到 Nginx(自动推送证书并做回源自检)

@@ -485,6 +495,18 @@ const rollingBack = ref(false) const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null) const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null) +// frps 回源端口落在 80/443 时,VPS 上由 frps 直接对外,没有 Nginx 的位置: +// 公网反代整体不适用,也不需要(更不应该)自动部署。 +const privilegedVhostPorts = computed(() => { + const options = server.value?.options + if (!options) return [] + const ports: string[] = [] + if ((options.vhost_http_port || 8080) === 80) ports.push('80') + if ((options.vhost_https_port || 8443) === 443) ports.push('443') + return ports +}) +const publicProxyInapplicable = computed(() => privilegedVhostPorts.value.length > 0) + // 统计:已生效 / 待处理 / 健康通过 / 当前连接 const deployedCount = computed(() => candidates.value.filter((item) => stateOf(item).state === 'loaded').length) const pendingCount = computed(() => candidates.value.length - deployedCount.value) @@ -541,6 +563,16 @@ async function load() { async function refreshAll(options: { silent?: boolean } = { silent: true }) { const current = server.value if (!current) return + // 80/443 回源端口下公网反代不适用:清空台账,也不必再去问 agent 的路由/漂移/健康 + if (publicProxyInapplicable.value) { + candidates.value = [] + routeDetails.value = [] + routeHealth.value = [] + routeDrift.value = null + agentStatus.value = null + statusLoaded.value = false + return + } if (options.silent === false) manualRefreshing.value = true const failures: string[] = [] const track = async (label: string, fn: () => Promise, assign: (value: T) => void) => {