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 生成、反代回源跟随与守卫对照
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -241,6 +241,10 @@ type ServerInput struct {
|
||||
ClearTLSCertificate bool
|
||||
ClearTLSKey bool
|
||||
ClearTLSTrustedCA bool
|
||||
|
||||
// AllowPrivilegedVhostPorts 是本次请求的二次确认标记:vhost 回源端口填 80/443 时必须为 true。
|
||||
// 只作用于本次校验,不落库。
|
||||
AllowPrivilegedVhostPorts bool
|
||||
}
|
||||
|
||||
type ProxyInput struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
+18
-4
@@ -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
|
||||
|
||||
@@ -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/\\\"'") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user